ba199f24bd
- Defer quiet-hours dispatches into new deferred_dispatch table; drain job + periodic catch-up scan re-fire at window end with coalescing on (link, event_type, collection_id). - Add ON DELETE SET NULL migration on event_log_id and partial unique index on (link_id, collection_id, event_type) WHERE status='pending'. - Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe URL validation, settings UI cassette, and scheduled polling. - Replace importlib-only version lookup with version.py helper that prefers the higher of installed metadata vs source pyproject so stale editable dev installs stop misreporting. - Aurora frontend polish: MetaStrip component, ReleaseCassette, EventDetailModal expansion, and i18n additions.
236 lines
8.2 KiB
Python
236 lines
8.2 KiB
Python
"""Tests for the release provider abstraction and Gitea probe."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from notify_bridge_core.release import build_release_provider, is_valid_repo
|
|
from notify_bridge_core.release.base import (
|
|
ReleaseErrorCode,
|
|
ReleaseProviderKind,
|
|
compare_versions,
|
|
is_newer,
|
|
normalise_version,
|
|
)
|
|
from notify_bridge_core.release.gitea import GiteaReleaseProvider
|
|
|
|
|
|
# --- pure utilities ---------------------------------------------------------
|
|
|
|
|
|
def test_normalise_version_strips_v_prefix() -> None:
|
|
assert normalise_version("v1.2.3") == "1.2.3"
|
|
assert normalise_version("V1.2.3") == "1.2.3"
|
|
assert normalise_version("1.2.3") == "1.2.3"
|
|
assert normalise_version("") == ""
|
|
# Only strip ``v`` when followed by a digit — guard against names like
|
|
# ``vendor-1`` being mangled into ``endor-1``.
|
|
assert normalise_version("vendor-1") == "vendor-1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("a", "b", "expected"),
|
|
[
|
|
("0.7.3", "0.7.2", 1),
|
|
("0.7.2", "0.7.3", -1),
|
|
("0.7.2", "0.7.2", 0),
|
|
("v0.7.3", "0.7.2", 1),
|
|
("1.0.0", "0.9.99", 1),
|
|
# Stable beats prerelease at equal numerics (tie-break).
|
|
("0.7.2-rc1", "0.7.2", -1),
|
|
("0.7.2", "0.7.2-rc1", 1),
|
|
# Implicit prerelease form ``1.0a2`` must NOT extract ``2`` as a
|
|
# third numeric segment — equal to ``1.0`` stable, then stable wins.
|
|
("1.0a2", "1.0", -1),
|
|
("", "0.0.0", 0),
|
|
],
|
|
)
|
|
def test_compare_versions(a: str, b: str, expected: int) -> None:
|
|
assert compare_versions(a, b) == expected
|
|
|
|
|
|
def test_is_newer_is_strict() -> None:
|
|
assert is_newer("0.7.3", "0.7.2") is True
|
|
assert is_newer("0.7.2", "0.7.2") is False
|
|
# A pre-release of the next minor should still be flagged as newer when
|
|
# explicitly fetched with include_prereleases=True at the provider level.
|
|
assert is_newer("0.7.3-rc1", "0.7.2") is True
|
|
|
|
|
|
def test_is_valid_repo() -> None:
|
|
assert is_valid_repo("alexei.dolgolyov/notify-bridge") is True
|
|
assert is_valid_repo("a/b") is True
|
|
assert is_valid_repo("a_b/c.d-e") is True
|
|
assert is_valid_repo("") is False
|
|
assert is_valid_repo("no-slash") is False
|
|
# Path-traversal attempts.
|
|
assert is_valid_repo("foo/bar/../admin") is False
|
|
assert is_valid_repo("foo/bar/baz") is False
|
|
assert is_valid_repo("foo/../bar") is False
|
|
# Embedded special chars.
|
|
assert is_valid_repo("foo@bar/baz") is False
|
|
assert is_valid_repo("foo/bar?x=1") is False
|
|
|
|
|
|
# --- registry ---------------------------------------------------------------
|
|
|
|
|
|
def test_registry_returns_none_for_disabled() -> None:
|
|
assert build_release_provider("disabled", session=MagicMock(), url="x", repo="a/b") is None
|
|
|
|
|
|
def test_registry_returns_none_for_unknown_kind() -> None:
|
|
assert build_release_provider("svn", session=MagicMock(), url="x", repo="a/b") is None
|
|
|
|
|
|
def test_registry_gitea_requires_url_and_valid_repo() -> None:
|
|
sess = MagicMock()
|
|
assert build_release_provider("gitea", session=sess, url="", repo="a/b") is None
|
|
assert build_release_provider("gitea", session=sess, url="https://x", repo="") is None
|
|
# Path traversal blocked by repo validation.
|
|
assert build_release_provider("gitea", session=sess, url="https://x", repo="a/b/../c") is None
|
|
provider = build_release_provider("gitea", session=sess, url="https://x", repo="a/b")
|
|
assert isinstance(provider, GiteaReleaseProvider)
|
|
assert provider.kind is ReleaseProviderKind.GITEA
|
|
|
|
|
|
# --- Gitea provider ---------------------------------------------------------
|
|
|
|
|
|
def _gitea_payload(**overrides: Any) -> list[dict[str, Any]]:
|
|
base = {
|
|
"tag_name": "v0.7.3",
|
|
"name": "v0.7.3",
|
|
"html_url": "https://git.example.com/owner/repo/releases/tag/v0.7.3",
|
|
"body": "Notes",
|
|
"published_at": "2026-05-01T00:00:00Z",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}
|
|
base.update(overrides)
|
|
return [base]
|
|
|
|
|
|
class _FakeContent:
|
|
def __init__(self, raw: bytes) -> None:
|
|
self._raw = raw
|
|
|
|
async def read(self, n: int = -1) -> bytes:
|
|
return self._raw if n < 0 else self._raw[:n]
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status: int, payload: Any) -> None:
|
|
self.status = status
|
|
import json
|
|
|
|
self.content = _FakeContent(json.dumps(payload).encode("utf-8"))
|
|
self._payload = payload
|
|
|
|
async def json(self) -> Any:
|
|
return self._payload
|
|
|
|
async def __aenter__(self) -> "_FakeResponse":
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
|
|
def _session_with(payload: Any, status: int = 200) -> MagicMock:
|
|
"""Return a session whose `.get()` yields a fresh response per call.
|
|
|
|
Using ``side_effect`` rather than ``return_value`` ensures multiple
|
|
awaited fetches don't share mutable response state across tests.
|
|
"""
|
|
sess = MagicMock()
|
|
sess.get = MagicMock(side_effect=lambda *a, **kw: _FakeResponse(status, payload))
|
|
return sess
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _allow_private_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""SSRF guard rejects example.com → publicly resolvable, so tests pass.
|
|
|
|
But we explicitly enable the bypass to remove DNS-resolution flakiness
|
|
from CI runs.
|
|
"""
|
|
monkeypatch.setenv("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS", "1")
|
|
# Reload the ssrf module to pick up the env var (it's read at import).
|
|
import importlib
|
|
|
|
import notify_bridge_core.notifications.ssrf as ssrf_mod
|
|
importlib.reload(ssrf_mod)
|
|
|
|
|
|
async def test_gitea_fetch_latest_happy_path() -> None:
|
|
sess = _session_with(_gitea_payload())
|
|
provider = GiteaReleaseProvider(sess, "https://git.example.com/", "owner/repo")
|
|
|
|
info = await provider.fetch_latest(include_prereleases=False)
|
|
assert info is not None
|
|
assert info.tag == "v0.7.3"
|
|
assert info.version == "0.7.3"
|
|
assert info.url == "https://git.example.com/owner/repo/releases/tag/v0.7.3"
|
|
assert info.prerelease is False
|
|
|
|
|
|
async def test_gitea_skips_prereleases_by_default() -> None:
|
|
payload = _gitea_payload(prerelease=True)
|
|
sess = _session_with(payload)
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
assert await provider.fetch_latest(include_prereleases=False) is None
|
|
|
|
|
|
async def test_gitea_includes_prereleases_when_asked() -> None:
|
|
payload = _gitea_payload(prerelease=True)
|
|
sess = _session_with(payload)
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
info = await provider.fetch_latest(include_prereleases=True)
|
|
assert info is not None
|
|
assert info.prerelease is True
|
|
|
|
|
|
async def test_gitea_skips_drafts() -> None:
|
|
payload = _gitea_payload(draft=True)
|
|
sess = _session_with(payload)
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
assert await provider.fetch_latest(include_prereleases=True) is None
|
|
|
|
|
|
async def test_gitea_returns_none_on_http_error() -> None:
|
|
sess = _session_with([], status=500)
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
assert await provider.fetch_latest() is None
|
|
|
|
|
|
async def test_gitea_test_returns_structured_status() -> None:
|
|
sess = _session_with(_gitea_payload())
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
result = await provider.test()
|
|
assert result["ok"] is True
|
|
assert result["info"] is not None
|
|
assert result["error"] is None
|
|
|
|
|
|
async def test_gitea_test_reports_http_error() -> None:
|
|
sess = _session_with([], status=404)
|
|
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
|
|
result = await provider.test()
|
|
assert result["ok"] is False
|
|
assert result["info"] is None
|
|
# Taxonomy code, not a raw exception string.
|
|
assert result["error"] in {code.value for code in ReleaseErrorCode}
|
|
|
|
|
|
def test_gitea_constructor_validates_repo_format() -> None:
|
|
with pytest.raises(ValueError):
|
|
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "no-slash")
|
|
with pytest.raises(ValueError):
|
|
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "foo/bar/../baz")
|
|
with pytest.raises(ValueError):
|
|
GiteaReleaseProvider(MagicMock(), "", "owner/repo")
|