Implement real Matrix notification for coe-mirror
Replace stub notify() with full Synapse integration targeting General room. - Add _send_matrix(), _get_matrix_token(), _matrix_login() helpers - Implement token cache with 30-min refresh threshold and 401 retry - Fetch credentials via Infisical (MATRIX_USER, MATRIX_PASSWORD from matrix/) - Add MATRIX_* env overrides for testing (homeserver, room, token cache path) - Non-fatal failures: Matrix outage logs warning, does not break mirror run - Add 5 comprehensive test cases covering success, failure, 401 refresh, env override, chmod 600 - All 18 tests passing - Update README and notes.md with implementation details
This commit is contained in:
@@ -589,3 +589,238 @@ class TestPlaceIntoPloudSameFileIsNoop:
|
||||
# Assert file still exists and has same content
|
||||
assert test_file.exists()
|
||||
assert test_file.stat().st_size > 0
|
||||
|
||||
|
||||
# Tests for Matrix notification implementation.
|
||||
# Note: Tests requiring monkeypatched subprocess.run must use raising=False because
|
||||
# subprocess.run is not a class attribute on the coe_mirror module by default
|
||||
# — patch via monkeypatch.setattr("coe_mirror.subprocess.run", fake_run).
|
||||
|
||||
|
||||
class TestMatrixSendSuccess:
|
||||
"""Test successful Matrix message send with cached token."""
|
||||
|
||||
def test_matrix_send_success(self, mock_paths, setup_logging, monkeypatch):
|
||||
"""Pre-write valid token, mock send endpoint 200, assert returns True."""
|
||||
# Pre-write a valid (un-expired) token to cache
|
||||
token_cache_file = mock_paths["log_dir"] / "matrix-token.json"
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
future_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
cache_data = {
|
||||
"access_token": "cached-token-12345",
|
||||
"expires_at": future_time.isoformat()
|
||||
}
|
||||
token_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(token_cache_file, "w") as f:
|
||||
json.dump(cache_data, f)
|
||||
os.chmod(token_cache_file, 0o600)
|
||||
|
||||
# Patch MATRIX_TOKEN_CACHE to point to our temp file
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_TOKEN_CACHE", token_cache_file)
|
||||
|
||||
# Mock send endpoint to return 200
|
||||
with requests_mock.Mocker() as m:
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/rooms/"
|
||||
"%21REDACTEDROOM%3Am.example.org/send/m.room.message",
|
||||
json={"event_id": "$event1"},
|
||||
status_code=200
|
||||
)
|
||||
|
||||
# Call _send_matrix
|
||||
result = coe_mirror._send_matrix("hello")
|
||||
|
||||
# Assert success
|
||||
assert result is True
|
||||
# Verify the request was made
|
||||
assert m.called
|
||||
|
||||
|
||||
class TestMatrixSendFailureNonfatal:
|
||||
"""Test that Matrix send failure is non-fatal."""
|
||||
|
||||
def test_matrix_send_failure_nonfatal(self, mock_paths, setup_logging, monkeypatch):
|
||||
"""Pre-write token, mock send 500, assert returns False, no exception."""
|
||||
token_cache_file = mock_paths["log_dir"] / "matrix-token.json"
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
future_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
cache_data = {
|
||||
"access_token": "cached-token-12345",
|
||||
"expires_at": future_time.isoformat()
|
||||
}
|
||||
token_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(token_cache_file, "w") as f:
|
||||
json.dump(cache_data, f)
|
||||
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_TOKEN_CACHE", token_cache_file)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/rooms/"
|
||||
"%21REDACTEDROOM%3Am.example.org/send/m.room.message",
|
||||
status_code=500,
|
||||
text="Internal Server Error"
|
||||
)
|
||||
|
||||
result = coe_mirror._send_matrix("hello")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMatrixTokenRefreshOn401:
|
||||
"""Test that 401 on send triggers token refresh and retry."""
|
||||
|
||||
def test_matrix_token_refresh_on_401(self, mock_paths, setup_logging, monkeypatch):
|
||||
"""Pre-write token, mock send 401 then 200, mock login, assert refreshes and succeeds."""
|
||||
token_cache_file = mock_paths["log_dir"] / "matrix-token.json"
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
future_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
cache_data = {
|
||||
"access_token": "old-token",
|
||||
"expires_at": future_time.isoformat()
|
||||
}
|
||||
token_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(token_cache_file, "w") as f:
|
||||
json.dump(cache_data, f)
|
||||
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_TOKEN_CACHE", token_cache_file)
|
||||
|
||||
# Mock creds to return user/pass
|
||||
fake_result_user = MagicMock()
|
||||
fake_result_user.returncode = 0
|
||||
fake_result_user.stdout = "test_user"
|
||||
|
||||
fake_result_pass = MagicMock()
|
||||
fake_result_pass.returncode = 0
|
||||
fake_result_pass.stdout = "test_pass"
|
||||
|
||||
def mock_subprocess_run(cmd, **kwargs):
|
||||
if cmd[2] == "MATRIX_USER":
|
||||
return fake_result_user
|
||||
elif cmd[2] == "MATRIX_PASSWORD":
|
||||
return fake_result_pass
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
|
||||
monkeypatch.setattr(coe_mirror.subprocess, "run", mock_subprocess_run)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
# First send returns 401
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/rooms/"
|
||||
"%21REDACTEDROOM%3Am.example.org/send/m.room.message",
|
||||
[
|
||||
{"status_code": 401, "text": "Unauthorized"},
|
||||
{"status_code": 200, "json": {"event_id": "$event1"}}
|
||||
]
|
||||
)
|
||||
|
||||
# Login endpoint
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/login",
|
||||
json={"access_token": "new-token", "expires_in_ms": 604800000}
|
||||
)
|
||||
|
||||
result = coe_mirror._send_matrix("hello")
|
||||
|
||||
# Assert succeeded after refresh
|
||||
assert result is True
|
||||
|
||||
# Verify new token is in cache
|
||||
with open(token_cache_file) as f:
|
||||
cached = json.load(f)
|
||||
assert cached["access_token"] == "new-token"
|
||||
|
||||
|
||||
class TestMatrixRoomEnvOverride:
|
||||
"""Test that COE_MIRROR_MATRIX_ROOM env var overrides the default room."""
|
||||
|
||||
def test_matrix_room_env_override(self, mock_paths, setup_logging, monkeypatch):
|
||||
"""Set env override, assert send POSTs to override room URL."""
|
||||
token_cache_file = mock_paths["log_dir"] / "matrix-token.json"
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
future_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
cache_data = {
|
||||
"access_token": "test-token",
|
||||
"expires_at": future_time.isoformat()
|
||||
}
|
||||
token_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(token_cache_file, "w") as f:
|
||||
json.dump(cache_data, f)
|
||||
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_TOKEN_CACHE", token_cache_file)
|
||||
|
||||
# Override the room
|
||||
override_room = "!override:example.org"
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_ROOM", override_room)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/rooms/"
|
||||
"%21override%3Aexample.org/send/m.room.message",
|
||||
json={"event_id": "$event1"},
|
||||
status_code=200
|
||||
)
|
||||
|
||||
result = coe_mirror._send_matrix("hello")
|
||||
|
||||
assert result is True
|
||||
assert m.called
|
||||
|
||||
|
||||
class TestMatrixLoginWritesCacheChmod600:
|
||||
"""Test that _matrix_login writes cache file with 0600 permissions."""
|
||||
|
||||
def test_matrix_login_writes_cache_chmod_600(self, mock_paths, setup_logging, monkeypatch):
|
||||
"""Start with no cache, mock creds and login, assert cache exists with 0o600."""
|
||||
token_cache_file = mock_paths["log_dir"] / "matrix-token.json"
|
||||
|
||||
monkeypatch.setattr(coe_mirror, "MATRIX_TOKEN_CACHE", token_cache_file)
|
||||
|
||||
# Mock creds to return user/pass
|
||||
fake_result_user = MagicMock()
|
||||
fake_result_user.returncode = 0
|
||||
fake_result_user.stdout = "testuser"
|
||||
|
||||
fake_result_pass = MagicMock()
|
||||
fake_result_pass.returncode = 0
|
||||
fake_result_pass.stdout = "testpass"
|
||||
|
||||
def mock_subprocess_run(cmd, **kwargs):
|
||||
if cmd[2] == "MATRIX_USER":
|
||||
return fake_result_user
|
||||
elif cmd[2] == "MATRIX_PASSWORD":
|
||||
return fake_result_pass
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
|
||||
monkeypatch.setattr(coe_mirror.subprocess, "run", mock_subprocess_run)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.post(
|
||||
"https://m.example.org/_matrix/client/r0/login",
|
||||
json={"access_token": "new-token", "expires_in_ms": 604800000}
|
||||
)
|
||||
|
||||
session = requests.Session()
|
||||
token = coe_mirror._get_matrix_token(session, force_refresh=True)
|
||||
|
||||
assert token == "new-token"
|
||||
assert token_cache_file.exists()
|
||||
|
||||
# Check permissions
|
||||
import stat
|
||||
mode = stat.S_IMODE(token_cache_file.stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
# Check content
|
||||
import json
|
||||
with open(token_cache_file) as f:
|
||||
cached = json.load(f)
|
||||
assert cached["access_token"] == "new-token"
|
||||
|
||||
Reference in New Issue
Block a user