diff --git a/.github/workflows/test-devel.yml b/.github/workflows/test-devel.yml index 41203ef8..680e3e3e 100644 --- a/.github/workflows/test-devel.yml +++ b/.github/workflows/test-devel.yml @@ -990,6 +990,7 @@ jobs: cache-on-failure: true workspaces: sable -> target - run: rustc --version + - run: sudo systemctl start postgresql.service - name: Build Sable run: | cd $GITHUB_WORKSPACE/sable/ @@ -1003,7 +1004,8 @@ jobs: - env: IRCTEST_DEBUG_LOGS: ${{ runner.debug }} name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH + IRCTEST_POSTGRESQL_URL=postgresql:// PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH make sable timeout-minutes: 30 - if: always() diff --git a/.github/workflows/test-stable.yml b/.github/workflows/test-stable.yml index 76ef5a6c..2987c85f 100644 --- a/.github/workflows/test-stable.yml +++ b/.github/workflows/test-stable.yml @@ -1140,7 +1140,7 @@ jobs: uses: actions/checkout@v4 with: path: sable - ref: 52397dc9e0f27c3ed197f984c00f06639870716d + ref: aee63093bb6ee250b24aabb843b863ab0a327e7a repository: Libera-Chat/sable - name: Install rust toolchain uses: actions-rs/toolchain@v1 @@ -1154,6 +1154,7 @@ jobs: cache-on-failure: true workspaces: sable -> target - run: rustc --version + - run: sudo systemctl start postgresql.service - name: Build Sable run: | cd $GITHUB_WORKSPACE/sable/ @@ -1167,7 +1168,8 @@ jobs: - env: IRCTEST_DEBUG_LOGS: ${{ runner.debug }} name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH + IRCTEST_POSTGRESQL_URL=postgresql:// PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH make sable timeout-minutes: 30 - if: always() diff --git a/Makefile b/Makefile index b85ce666..c92ad707 100644 --- a/Makefile +++ b/Makefile @@ -8,71 +8,72 @@ PYTEST_ARGS ?= EXTRA_SELECTORS ?= BAHAMUT_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ and not IRCv3 \ $(EXTRA_SELECTORS) CHARYBDIS_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) ERGO_SELECTORS := \ + (Ergo or not implementation-specific) \ not deprecated \ $(EXTRA_SELECTORS) HYBRID_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ $(EXTRA_SELECTORS) INSPIRCD_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) IRCU2_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) NEFARIOUS_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) SNIRCD_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) IRC2_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) MAMMON_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) NGIRCD_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) PLEXUS4_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ $(EXTRA_SELECTORS) @@ -85,7 +86,7 @@ LIMNORIA_SELECTORS := \ # Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet SABLE_SELECTORS := \ - not Ergo \ + (Sable or not implementation-specific) \ and not deprecated \ and not strict \ and not arbitrary_client_tags \ @@ -94,7 +95,7 @@ SABLE_SELECTORS := \ $(EXTRA_SELECTORS) SOLANUM_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ $(EXTRA_SELECTORS) @@ -116,7 +117,7 @@ THELOUNGE_SELECTORS := \ # Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs UNREALIRCD_SELECTORS := \ - not Ergo \ + not implementation-specific \ and not deprecated \ and not strict \ and not arbitrary_client_tags \ diff --git a/irctest/basecontrollers.py b/irctest/basecontrollers.py index 156d5c3f..0f394ecb 100644 --- a/irctest/basecontrollers.py +++ b/irctest/basecontrollers.py @@ -8,8 +8,10 @@ import shutil import socket import subprocess +import sys import tempfile import textwrap +import threading import time from typing import ( IO, @@ -67,6 +69,9 @@ class TestCaseControllerConfig: This should be used as little as possible, using the other attributes instead; as they are work with any controller.""" + sable_history_server: bool = False + """Whether to start Sable's long-term history server""" + class _BaseController: """Base class for software controllers. @@ -145,10 +150,48 @@ def kill(self) -> None: self._own_ports.remove((hostname, port)) def execute( - self, command: Sequence[Union[str, Path]], **kwargs: Any + self, + command: Sequence[Union[str, Path]], + proc_name: Optional[str] = None, + **kwargs: Any, ) -> subprocess.Popen: output_to = None if self.debug_mode else subprocess.DEVNULL - return subprocess.Popen(command, stderr=output_to, stdout=output_to, **kwargs) + proc_name = proc_name or str(command[0]) + kwargs.setdefault("stdout", output_to) + kwargs.setdefault("stderr", output_to) + stream_stdout = stream_stderr = None + if kwargs["stdout"] in (None, subprocess.STDOUT): + kwargs["stdout"] = subprocess.PIPE + + def stream_stdout() -> None: + assert proc.stdout is not None # for mypy + for line in proc.stdout: + prefix = f"{time.time():.3f} {proc_name} ".encode() + try: + sys.stdout.buffer.write(prefix + line) + except ValueError: + # "I/O operation on closed file" + pass + + if kwargs["stderr"] in (subprocess.STDOUT, None): + kwargs["stdout"] = subprocess.PIPE + + def stream_stderr() -> None: + assert proc.stderr is not None # for mypy + for line in proc.stderr: + prefix = f"{time.time():.3f} {proc_name} ".encode() + try: + sys.stdout.buffer.write(prefix + line) + except ValueError: + # "I/O operation on closed file" + pass + + proc = subprocess.Popen(command, **kwargs) + if stream_stdout is not None: + threading.Thread(target=stream_stdout, name="stream_stdout").start() + if stream_stderr is not None: + threading.Thread(target=stream_stderr, name="stream_stderr").start() + return proc class DirectoryBasedController(_BaseController): diff --git a/irctest/cases.py b/irctest/cases.py index 4d14b3a2..7ab0e690 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -842,16 +842,22 @@ def mark_services(cls: TClass) -> TClass: def mark_specifications( *specifications_str: str, deprecated: bool = False, strict: bool = False ) -> Callable[[TCallable], TCallable]: - specifications = frozenset( + specifications = { Specifications.from_name(s) if isinstance(s, str) else s for s in specifications_str - ) + } if None in specifications: raise ValueError("Invalid set of specifications: {}".format(specifications)) + is_implementation_specific = all( + spec.is_implementation_specific() for spec in specifications + ) + def decorator(f: TCallable) -> TCallable: for specification in specifications: f = getattr(pytest.mark, specification.value)(f) + if is_implementation_specific: + f = getattr(pytest.mark, "implementation-specific")(f) if strict: f = pytest.mark.strict(f) if deprecated: diff --git a/irctest/controllers/sable.py b/irctest/controllers/sable.py index 200ea6fa..df6313a0 100644 --- a/irctest/controllers/sable.py +++ b/irctest/controllers/sable.py @@ -5,7 +5,7 @@ import subprocess import tempfile import time -from typing import Optional, Type +from typing import Optional, Sequence, Type from irctest.basecontrollers import ( BaseServerController, @@ -85,7 +85,13 @@ def certs_dir() -> Path: certs_dir = tempfile.TemporaryDirectory() (Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS) subprocess.run( - ["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"], + [ + "bash", + "gen_certs.sh", + "My.Little.Server", + "My.Little.History", + "My.Little.Services", + ], cwd=certs_dir.name, check=True, ) @@ -95,10 +101,11 @@ def certs_dir() -> Path: NETWORK_CONFIG = """ { - "fanout": 1, + "fanout": 2, "ca_file": "%(certs_dir)s/ca_cert.pem", "peers": [ + { "name": "My.Little.History", "address": "%(history_hostname)s:%(history_port)s", "fingerprint": "%(history_cert_sha1)s" }, { "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" }, { "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" } ] @@ -107,7 +114,7 @@ def certs_dir() -> Path: NETWORK_CONFIG_CONFIG = """ { - "object_expiry": 300, + "object_expiry": 60, // 1 minute "opers": [ { @@ -219,6 +226,58 @@ def certs_dir() -> Path: } """ +HISTORY_SERVER_CONFIG = """ +{ + "server_id": 50, + "server_name": "My.Little.History", + + "management": { + "address": "%(history_management_hostname)s:%(history_management_port)s", + "client_ca": "%(certs_dir)s/ca_cert.pem", + "authorised_fingerprints": [ + { "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" } + ] + }, + + "server": { + "database": "%(history_db_url)s", + "auto_run_migrations": true, + }, + + "event_log": { + "event_expiry": 300, // five minutes, for local testing + }, + + "tls_config": { + "key_file": "%(certs_dir)s/My.Little.History.key", + "cert_file": "%(certs_dir)s/My.Little.History.pem" + }, + + "node_config": { + "listen_addr": "%(history_hostname)s:%(history_port)s", + "cert_file": "%(certs_dir)s/My.Little.History.pem", + "key_file": "%(certs_dir)s/My.Little.History.key" + }, + + "log": { + "dir": "log/services/", + + "module-levels": { + "": "debug", + "sable_history": "trace", + }, + + "targets": [ + { + "target": "stdout", + "level": "trace", + "modules": [ "sable" ] + } + ] + } +} +""" + SERVICES_CONFIG = """ { "server_id": 99, @@ -297,7 +356,7 @@ def certs_dir() -> Path: { "target": "stdout", "level": "debug", - "modules": [ "sable_services" ] + "modules": [ "sable" ] } ] } @@ -348,10 +407,11 @@ def run( (server1_hostname, server1_port) = self.get_hostname_and_port() (services_hostname, services_port) = self.get_hostname_and_port() + (history_hostname, history_port) = self.get_hostname_and_port() # Sable requires inbound connections to match the configured hostname, # so we can't configure 0.0.0.0 - server1_hostname = services_hostname = "127.0.0.1" + server1_hostname = history_hostname = services_hostname = "127.0.0.1" ( server1_management_hostname, @@ -361,6 +421,10 @@ def run( services_management_hostname, services_management_port, ) = self.get_hostname_and_port() + ( + history_management_hostname, + history_management_port, + ) = self.get_hostname_and_port() self.template_vars = dict( certs_dir=certs_dir(), @@ -381,6 +445,13 @@ def run( services_management_hostname=services_management_hostname, services_management_port=services_management_port, services_alias_users=SERVICES_ALIAS_USERS if run_services else "", + history_hostname=history_hostname, + history_port=history_port, + history_cert_sha1=(certs_dir() / "My.Little.History.pem.sha1") + .read_text() + .strip(), + history_management_hostname=history_management_hostname, + history_management_port=history_management_port, ) with self.open_file("configs/network.conf") as fd: @@ -411,17 +482,28 @@ def run( cwd=self.directory, preexec_fn=os.setsid, env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_ircd ", ) self.pgroup_id = os.getpgid(self.proc.pid) if run_services: self.services_controller = SableServicesController(self.test_config, self) + self.services_controller.faketime_cmd = faketime_cmd self.services_controller.run( protocol="sable", server_hostname=services_hostname, server_port=services_port, ) + if self.test_config.sable_history_server: + self.history_controller = SableHistoryController(self.test_config, self) + self.history_controller.faketime_cmd = faketime_cmd + self.history_controller.run( + protocol="sable", + server_hostname=history_hostname, + server_port=history_port, + ) + def kill_proc(self) -> None: os.killpg(self.pgroup_id, signal.SIGKILL) super().kill_proc() @@ -470,6 +552,8 @@ class SableServicesController(BaseServicesController): server_controller: SableController software_name = "Sable Services" + faketime_cmd: Sequence[str] + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: assert protocol == "sable" assert self.server_controller.directory is not None @@ -479,6 +563,7 @@ def run(self, protocol: str, server_hostname: str, server_port: int) -> None: self.proc = self.execute( [ + *self.faketime_cmd, "sable_services", "--foreground", "--server-conf", @@ -489,6 +574,52 @@ def run(self, protocol: str, server_hostname: str, server_port: int) -> None: cwd=self.server_controller.directory, preexec_fn=os.setsid, env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_services", + ) + self.pgroup_id = os.getpgid(self.proc.pid) + + def kill_proc(self) -> None: + os.killpg(self.pgroup_id, signal.SIGKILL) + super().kill_proc() + + +class SableHistoryController(BaseServicesController): + server_controller: SableController + software_name = "Sable History Server" + faketime_cmd: Sequence[str] + + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: + assert protocol == "sable" + assert self.server_controller.directory is not None + history_db_url = os.environ.get("PIFPAF_POSTGRESQL_URL") or os.environ.get( + "IRCTEST_POSTGRESQL_URL" + ) + assert history_db_url, ( + "Cannot find a postgresql database to use as backend for sable_history. " + "Either set the IRCTEST_POSTGRESQL_URL env var to a libpq URL, or " + "run `pip3 install pifpaf` and wrap irctest in a pifpaf call (ie. " + "pifpaf run postgresql -- pytest --controller=irctest.controllers.sable ...)" + ) + + with self.server_controller.open_file("configs/history_server.conf") as fd: + vals = dict(self.server_controller.template_vars) + vals["history_db_url"] = history_db_url + fd.write(HISTORY_SERVER_CONFIG % vals) + + self.proc = self.execute( + [ + *self.faketime_cmd, + "sable_history", + "--foreground", + "--server-conf", + self.server_controller.directory / "configs/history_server.conf", + "--network-conf", + self.server_controller.directory / "configs/network.conf", + ], + cwd=self.server_controller.directory, + preexec_fn=os.setsid, + env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_history ", ) self.pgroup_id = os.getpgid(self.proc.pid) diff --git a/irctest/server_tests/chathistory.py b/irctest/server_tests/chathistory.py index 71521b60..2a7a2a1a 100644 --- a/irctest/server_tests/chathistory.py +++ b/irctest/server_tests/chathistory.py @@ -2,6 +2,7 @@ `IRCv3 draft chathistory `_ """ +import dataclasses import functools import secrets import time @@ -31,10 +32,22 @@ def newf(self, *args, **kwargs): return newf -@cases.mark_specifications("IRCv3") -@cases.mark_services -class ChathistoryTestCase(cases.BaseServerTestCase): - def validate_chathistory_batch(self, msgs, target): +class _BaseChathistoryTests(cases.BaseServerTestCase): + def _wait_before_chathistory(self): + """Hook for the Sable-specific tests that check the postgresql-based + CHATHISTORY implementation is sound. This implementation only kicks in + after the in-memory history is cleared, which happens after a 5 min timeout; + and this gives a chance to :class:``SablePostgresqlHistoryTestCase`` to + wait this timeout. + + For other tests, this does nothing. + """ + raise NotImplementedError("_BaseChathistoryTests._wait_before_chathistory") + + def validate_chathistory_batch(self, user, target): + # may need to try again for Sable, as it has a pretty high latency here + while not (msgs := self.getMessages(user)): + pass (start, *inner_msgs, end) = msgs self.assertMessageMatch( @@ -94,9 +107,13 @@ def testInvalidTargets(self): self.joinChannel(qux, real_chname) self.getMessages(qux) + self._wait_before_chathistory() + # test a nonexistent channel self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10") - msgs = self.getMessages(bar) + while not (msgs := self.getMessages(bar)): + # need to retry when Sable has the history server on + pass msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r self.assertMessageMatch( msgs[0], @@ -106,7 +123,9 @@ def testInvalidTargets(self): # as should a real channel to which one is not joined: self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,)) - msgs = self.getMessages(bar) + while not (msgs := self.getMessages(bar)): + # need to retry when Sable has the history server on + pass self.assertMessageMatch( msgs[0], command="FAIL", @@ -175,6 +194,8 @@ def testMessagesToSelf(self): messages.append(echo.to_history_message()) self.assertEqual(echo.to_history_message(), delivery.to_history_message()) + self._wait_before_chathistory() + self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,)) replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"] self.assertEqual([msg.to_history_message() for msg in replies], messages) @@ -225,9 +246,12 @@ def testChathistory(self, subcommand): echo_messages.extend( msg.to_history_message() for msg in self.getMessages(1) ) - time.sleep(0.002) + time.sleep(0.02) self.validate_echo_messages(NUM_MESSAGES, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, chname) @skip_ngircd @@ -264,6 +288,8 @@ def testChathistoryNoEventPlayback(self): ) time.sleep(0.002) + self._wait_before_chathistory() + self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname) (batch_open, *messages, batch_close) = self.getMessages(1) @@ -308,6 +334,9 @@ def testChathistoryEventPlayback(self, subcommand): time.sleep(0.002) self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, chname) @pytest.mark.parametrize("subcommand", SUBCOMMANDS) @@ -367,6 +396,9 @@ def testChathistoryDMs(self, subcommand): self.getMessages(2) self.validate_echo_messages(NUM_MESSAGES, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 2, c1) @@ -415,6 +447,8 @@ def testChathistoryDMs(self, subcommand): ] self.assertEqual(results, new_convo) + self._wait_before_chathistory() + # additional messages with c3 should not show up in the c1-c2 history: self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 2, c1) @@ -459,15 +493,15 @@ def validate_chathistory(self, subcommand, echo_messages, user, chname): def _validate_chathistory_LATEST(self, echo_messages, user, chname): INCLUSIVE_LIMIT = len(echo_messages) * 2 self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages, result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-5:], result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-1:], result) if self._supports_msgid(): @@ -476,7 +510,7 @@ def _validate_chathistory_LATEST(self, echo_messages, user, chname): "CHATHISTORY LATEST %s msgid=%s %d" % (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[5:], result) if self._supports_timestamp(): @@ -485,7 +519,7 @@ def _validate_chathistory_LATEST(self, echo_messages, user, chname): "CHATHISTORY LATEST %s timestamp=%s %d" % (chname, echo_messages[4].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[5:], result) def _validate_chathistory_BEFORE(self, echo_messages, user, chname): @@ -496,7 +530,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s msgid=%s %d" % (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[:6], result) if self._supports_timestamp(): @@ -505,7 +539,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[:6], result) self.sendLine( @@ -513,7 +547,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, 2), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:6], result) def _validate_chathistory_AFTER(self, echo_messages, user, chname): @@ -524,7 +558,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s msgid=%s %d" % (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:], result) if self._supports_timestamp(): @@ -533,7 +567,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:], result) self.sendLine( @@ -541,7 +575,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:7], result) def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): @@ -558,7 +592,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( @@ -571,7 +605,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) # BETWEEN forwards and backwards with a limit, should get @@ -581,7 +615,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( @@ -589,7 +623,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-4:-1], result) if self._supports_timestamp(): @@ -604,7 +638,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, @@ -616,21 +650,21 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[0].time, echo_messages[-1].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[-1].time, echo_messages[0].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-4:-1], result) def _validate_chathistory_AROUND(self, echo_messages, user, chname): @@ -640,7 +674,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual([echo_messages[7]], result) self.sendLine( @@ -648,7 +682,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[6:9], result) if self._supports_timestamp(): @@ -657,7 +691,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s timestamp=%s %d" % (chname, echo_messages[7].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertIn(echo_messages[7], result) @pytest.mark.arbitrary_client_tags @@ -718,6 +752,8 @@ def validate_tagmsg(msg, target, msgid): self.assertEqual(len(relay), 1) validate_tagmsg(relay[0], chname, msgid) + self._wait_before_chathistory() + self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,)) history_tagmsgs = [ msg for msg in self.getMessages(1) if msg.command == "TAGMSG" @@ -814,8 +850,95 @@ def validate_msg(msg): validate_msg(relay) +@cases.mark_specifications("IRCv3") +@cases.mark_services +class ChathistoryTestCase(_BaseChathistoryTests): + def _wait_before_chathistory(self): + """does nothing""" + pass + + assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == { meth_name for meth_name in dir(ChathistoryTestCase) if meth_name.startswith("_validate_chathistory_") }, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync" + + +@cases.mark_specifications("Sable") +@cases.mark_services +class SablePostgresqlHistoryTestCase(_BaseChathistoryTests): + # for every wall clock second, 15 seconds pass for the server. + # at x30, links between nodes timeout. + faketime = "+1y x15" + + @staticmethod + def config() -> cases.TestCaseControllerConfig: + return dataclasses.replace( # type: ignore[no-any-return] + _BaseChathistoryTests.config(), + sable_history_server=True, + ) + + def _wait_before_chathistory(self): + """waits 6 seconds which appears to be a 1.5 min to Sable; which goes over + the 1 min timeout for in-memory history (+ 1 min because the cleanup job + only runs every min)""" + assert self.controller.faketime_enabled, "faketime is not installed" + time.sleep(8) + + +@cases.mark_specifications("Sable") +@cases.mark_services +class SableExpiringHistoryTestCase(cases.BaseServerTestCase): + faketime = "+1y x15" + + def _wait_before_chathistory(self): + """waits 6 seconds which appears to be a 1.5 min to Sable; which goes over + the 1 min timeout for in-memory history (+ 1 min because the cleanup job + only runs every min)""" + assert self.controller.faketime_enabled, "faketime is not installed" + time.sleep(8) + + def testChathistoryExpired(self): + """Checks that Sable forgets about messages if the history server is not available""" + self.connectClient( + "bar", + capabilities=[ + "message-tags", + "server-time", + "echo-message", + "batch", + "labeled-response", + "sasl", + CHATHISTORY_CAP, + ], + skip_if_cap_nak=True, + ) + chname = "#chan" + secrets.token_hex(12) + self.joinChannel(1, chname) + self.getMessages(1) + self.getMessages(1) + + self.sendLine(1, f"PRIVMSG {chname} :this is a message") + self.getMessages(1) + + self._wait_before_chathistory() + + self.sendLine(1, f"CHATHISTORY LATEST {chname} * 10") + + while not (messages := self.getMessages(1)): + # Sable processes CHATHISTORY asynchronously, which can be pretty slow as it + # sends cross-server requests. This means we can't just rely on a PING-PONG + # or the usual time.sleep(self.controller.sync_sleep_time) to make sure + # the ircd replied to us + time.sleep(self.controller.sync_sleep_time) + + (start, *middle, end) = messages + self.assertMessageMatch( + start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", chname] + ) + batch_tag = start.params[0][1:] + self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag]) + self.assertEqual( + len(middle), 0, f"Got messages that should be expired: {middle}" + ) diff --git a/irctest/specifications.py b/irctest/specifications.py index 9c4617bc..41f82b4c 100644 --- a/irctest/specifications.py +++ b/irctest/specifications.py @@ -9,6 +9,7 @@ class Specifications(enum.Enum): RFC2812 = "RFC2812" IRCv3 = "IRCv3" # Mark with capabilities whenever possible Ergo = "Ergo" + Sable = "Sable" Ircdocs = "ircdocs" """Any document on ircdocs.horse (especially defs.ircdocs.horse), @@ -24,6 +25,9 @@ def from_name(cls, name: str) -> Specifications: return spec raise ValueError(name) + def is_implementation_specific(self) -> bool: + return self in (Specifications.Ergo, Specifications.Sable) + @enum.unique class Capabilities(enum.Enum): diff --git a/pytest.ini b/pytest.ini index 375f2bb3..1c13017f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,7 +7,12 @@ markers = IRCv3 modern ircdocs + + # implementations for which we have specific test get two markers: + # the implementation name and 'implementation-specific' + implementation-specific Ergo + Sable # misc marks strict diff --git a/workflows.yml b/workflows.yml index fbf7b951..cf257d7e 100644 --- a/workflows.yml +++ b/workflows.yml @@ -249,7 +249,7 @@ software: name: Sable repository: Libera-Chat/sable refs: - stable: 52397dc9e0f27c3ed197f984c00f06639870716d + stable: aee63093bb6ee250b24aabb843b863ab0a327e7a release: null devel: master devel_release: null @@ -268,6 +268,9 @@ software: workspaces: "sable -> target" cache-on-failure: true - run: rustc --version + - run: start postgresql + run: "sudo systemctl start postgresql.service" + env: "IRCTEST_POSTGRESQL_URL=postgresql://" separate_build_job: false build_script: | cd $GITHUB_WORKSPACE/sable/