diff --git a/.github/workflows/integration-shodan.yml b/.github/workflows/integration-shodan.yml new file mode 100644 index 000000000..ed746584f --- /dev/null +++ b/.github/workflows/integration-shodan.yml @@ -0,0 +1,24 @@ +name: Integration - Shodan (manual) + +on: + workflow_dispatch: {} + +jobs: + shodan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + - name: Install dev deps + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Run Shodan integration test + env: + SHODAN_API_KEY: ${{ secrets.SHODAN_API_KEY }} + MAIGRET_EXTRA_SITES: ${{ github.workspace }}/sites_extra.json + run: | + PYTHONPATH="${{ github.workspace }}" pytest -q tests/test_shodan_integration.py -q diff --git a/README_EXTRA.md b/README_EXTRA.md new file mode 100644 index 000000000..263695c17 --- /dev/null +++ b/README_EXTRA.md @@ -0,0 +1,17 @@ +# Opt-in extra checkers (maigretexpanded) + +This fork supports *optional* additional site checkers (API-backed or extra scraping) kept separate from upstream Maigret. + +## Enabling extras locally + +1. Put extra-site definitions in `sites_extra.json` (root). Each key is an extra site id. The loader will read this file when `MAIGRET_EXTRA_SITES` points to it. + +2. Example: enable extras at runtime: +```bash +export MAIGRET_EXTRA_SITES="$(pwd)/sites_extra.json" + + +## Dev shims +This branch included small local shims (e.g. `maigret_sites_example/socid_extractor.py`) to let the +test suite run without pulling every heavy dependency. These are marked `DEV SHIM` and should be replaced +by the real dependency or removed before merging upstream if the maintainers prefer that. diff --git a/maigret/resources/data.json b/maigret/resources/data.json index f8010568d..209cc3f02 100644 --- a/maigret/resources/data.json +++ b/maigret/resources/data.json @@ -17537,7 +17537,7 @@ "method": "vimeo" }, "headers": { - "Authorization": "jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MzQxMTc1NDAsInVzZXJfaWQiOm51bGwsImFwcF9pZCI6NTg0NzksInNjb3BlcyI6InB1YmxpYyIsInRlYW1fdXNlcl9pZCI6bnVsbCwianRpIjoiNDc4Y2ZhZGUtZjI0Yy00MDVkLTliYWItN2RlNGEzNGM4MzI5In0.guN7Fg8dqq7EYdckrJ-6Rdkj_5MOl6FaC4YUSOceDpU" + "Authorization": "jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTk3MTkwMDAsInVzZXJfaWQiOm51bGwsImFwcF9pZCI6NTg0NzksInNjb3BlcyI6InB1YmxpYyIsInRlYW1fdXNlcl9pZCI6bnVsbCwianRpIjoiYmEzYTE0MDEtMTdkZS00ZGIxLTkzNjQtZGY1MDVkMzJkOWU1In0.GaK5Zn059lxEYy04lOq0eh9RCQWm4-a5uyNxfZKf6pg" }, "urlProbe": "https://api.vimeo.com/users/{username}?fields=name%2Cgender%2Cbio%2Curi%2Clink%2Cbackground_video%2Clocation_details%2Cpictures%2Cverified%2Cmetadata.public_videos.total%2Cavailable_for_hire%2Ccan_work_remotely%2Cmetadata.connections.videos.total%2Cmetadata.connections.albums.total%2Cmetadata.connections.followers.total%2Cmetadata.connections.following.total%2Cmetadata.public_videos.total%2Cmetadata.connections.vimeo_experts.is_enrolled%2Ctotal_collection_count%2Ccreated_time%2Cprofile_preferences%2Cmembership%2Cclients%2Cskills%2Cproject_types%2Crates%2Ccategories%2Cis_expert%2Cprofile_discovery%2Cwebsites%2Ccontact_emails&fetch_user_profile=1", "checkType": "status_code", diff --git a/maigret_sites_example/__init__.py b/maigret_sites_example/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/maigret_sites_example/example_site.py b/maigret_sites_example/example_site.py new file mode 100644 index 000000000..af626ffb9 --- /dev/null +++ b/maigret_sites_example/example_site.py @@ -0,0 +1,58 @@ +""" +Lightweight example checker module. + +This is intentionally defensive: + - If Maigret's BaseChecker is importable, we subclass it. + - Otherwise the module still exposes a callable `check(nickname, user_agent=None)` function + so you can wire it into Maigret's registry manually if needed. + +Adapt registration to Maigret internals (register this checker in their site registry). +""" +import requests + +try: + from maigret.checker import BaseChecker # best-effort import; adapt if path differs + _HAS_BASE = True +except Exception: + BaseChecker = object + _HAS_BASE = False + +class ExampleSiteChecker(BaseChecker): + site_name = "example_site" + + def __init__(self, user_agent=None): + self.user_agent = user_agent or "maigret/extended (+https://github.com/dmoney96/maigretexpanded)" + + def check(self, nickname): + url = f"https://www.example.com/{nickname}" + headers = {"User-Agent": self.user_agent} + try: + r = requests.get(url, headers=headers, timeout=10) + except Exception as e: + return {"status": "error", "error": str(e)} + + if r.status_code == 404: + return {"status": "not_found"} + + # JSON endpoint example + if "application/json" in r.headers.get("Content-Type", ""): + try: + data = r.json() + if data.get("profile") or data.get("exists"): + return {"status": "found", "url": url} + return {"status": "not_found"} + except Exception: + pass + + # HTML heuristics + text = r.text.lower() + if "class=\"profile-header\"" in r.text or "data-user-id" in r.text or "profile not found" not in text: + # basic positive heuristic (refine for real sites) + return {"status": "found", "url": url} + + return {"status": "unknown"} + +# convenience function for non-class consumers +def check(nickname, user_agent=None): + c = ExampleSiteChecker(user_agent=user_agent) + return c.check(nickname) diff --git a/maigret_sites_example/load_extras.py b/maigret_sites_example/load_extras.py new file mode 100644 index 000000000..74adb9d86 --- /dev/null +++ b/maigret_sites_example/load_extras.py @@ -0,0 +1,50 @@ +# maigret_sites_example/load_extras.py +"""Load and merge an extras JSON file into a default sites dict. + +Usage patterns: + - default_sites = load_extra_sites(default_sites) # runtime merge + - merged = merge_sites(default_sites, extra_path) # pure function + +This file intentionally lives outside `maigret/` to avoid colliding with upstream modules. +""" +from pathlib import Path +import json +import os +from typing import Dict, Any + +def read_json_path(path: str) -> Dict[str, Any]: + p = Path(path) + with p.open("r", encoding="utf-8") as fh: + return json.load(fh) + +def merge_sites(default_sites: Dict[str, Any], extra_path: str) -> Dict[str, Any]: + """ + Return a new dict with keys from extra_path merged in only when they do not exist. + Non-destructive: does not overwrite existing keys. + """ + if not extra_path: + return default_sites + p = Path(extra_path) + if not p.exists(): + return default_sites + try: + extra = read_json_path(extra_path) + except Exception: + # fail safe: return defaults if JSON invalid + return default_sites + + merged = dict(default_sites) # shallow copy + for k, v in extra.items(): + if k not in merged: + merged[k] = v + return merged + +def load_extra_sites(default_sites: Dict[str, Any], + env_var: str = "MAIGRET_EXTRA_SITES", + cli_path: str | None = None) -> Dict[str, Any]: + """ + Merge extras into default_sites. CLI path (explicit) takes precedence over environment var. + """ + extra_path = cli_path or os.getenv(env_var) + return merge_sites(default_sites, extra_path) + diff --git a/maigret_sites_example/mastodon_api_checker.py b/maigret_sites_example/mastodon_api_checker.py new file mode 100644 index 000000000..00763a2d8 --- /dev/null +++ b/maigret_sites_example/mastodon_api_checker.py @@ -0,0 +1,75 @@ +""" +Mastodon API-style checker (example). + +Uses the helper resolve_mastodon_api() (in mastodon_api_resolver.py) +which probes instances using the Mastodon accounts lookup endpoint. +This checker reports a hit when the resolver returns {"status": "found"}. +""" +from typing import Dict, Any, Optional +import os + +# import the resolver module (not the function) so tests that patch the +# function on the module will affect calls performed here. +from . import mastodon_api_resolver as resolver + +DEFAULT_RANK = 120 + + +def check(username: str, settings: Optional[object] = None, logger: Optional[object] = None, timeout: int = 6) -> Dict[str, Any]: + """ + Maigret-style checker for Mastodon-like handles. + + Args: + username: input username (may be '@name', 'name@instance' or 'name') + settings, logger: optional compatibility parameters (not used here) + timeout: passed to resolver + + Returns: + dict with keys at least: http_status, ids_usernames, parsing_enabled, rank, url, raw + """ + queried = username or "" + queried_stripped = queried.lstrip("@") + + # allow overriding the instance to probe via env var + instance_hint = os.getenv("MAIGRET_MASTODON_INSTANCE") + + try: + # call the resolver through the module so test patching works: + resolved = resolver.resolve_mastodon_api(queried, instance_hint=instance_hint, timeout=timeout) + except Exception as exc: + # Do not raise during checks — treat as not found; log if logger is present + if logger: + try: + logger.debug("mastodon resolver exception: %s", exc) + except Exception: + pass + resolved = {"status": "not_found"} + + # Default not-found result + result: Dict[str, Any] = { + "http_status": None, + "ids_usernames": {}, + "is_similar": False, + "parsing_enabled": False, + "rank": DEFAULT_RANK, + "url": None, + "raw": resolved, + } + + if resolved.get("status") == "found": + # Extract canonical username (drop leading '@' and any instance part) + canon = queried_stripped.split("@", 1)[0] + result.update( + { + "http_status": 200, + "ids_usernames": {canon: "username"}, + "is_similar": False, + # this checker provides a found profile URL / data so parsing_enabled = True + "parsing_enabled": True, + "rank": DEFAULT_RANK, + "url": resolved.get("url"), + "raw": resolved, + } + ) + + return result diff --git a/maigret_sites_example/mastodon_api_resolver.py b/maigret_sites_example/mastodon_api_resolver.py new file mode 100644 index 000000000..732d7cdc7 --- /dev/null +++ b/maigret_sites_example/mastodon_api_resolver.py @@ -0,0 +1,52 @@ +"""Resolve a Mastodon account using instance REST API (acct lookup).""" +from typing import Optional, Dict +import requests + +COMMON_INSTANCES = [ + "mastodon.social", + "fosstodon.org", + "mstdn.social", + "chaos.social", + "mastodon.cloud", +] + +DEFAULT_TIMEOUT = 6 +USER_AGENT = "maigret-extended/1.0 (+https://github.com/yourname/maigretexpanded)" + +def lookup_on_instance(nickname: str, instance: str, timeout: int = DEFAULT_TIMEOUT) -> Dict: + acct = nickname.lstrip("@").split("@")[0] + url = f"https://{instance}/api/v1/accounts/lookup" + params = {"acct": acct} + headers = {"User-Agent": USER_AGENT} + try: + r = requests.get(url, params=params, timeout=timeout, headers=headers) + if r.status_code == 200: + try: + j = r.json() + except Exception: + return {"status": "not_found"} + profile_url = j.get("url") or f"https://{instance}/@{acct}" + return {"status": "found", "url": profile_url, "data": j} + elif r.status_code in (404, 410): + return {"status": "not_found"} + else: + return {"status": "not_found", "raw_status": r.status_code} + except requests.RequestException: + return {"status": "not_found"} + +def resolve_mastodon_api(nickname: str, instance_hint: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT) -> Dict: + candidates = [] + if "@" in nickname and nickname.lstrip("@").count("@") == 1 and nickname.lstrip("@").split("@",1)[1]: + user, inst = nickname.lstrip("@").split("@",1) + candidates.append((user, inst)) + elif instance_hint: + candidates.append((nickname.lstrip("@"), instance_hint)) + else: + for inst in COMMON_INSTANCES: + candidates.append((nickname.lstrip("@"), inst)) + + for user, inst in candidates: + resp = lookup_on_instance(user, inst, timeout=timeout) + if resp.get("status") == "found": + return resp + return {"status": "not_found"} diff --git a/maigret_sites_example/mastodon_resolver.py b/maigret_sites_example/mastodon_resolver.py new file mode 100644 index 000000000..9d354c8e4 --- /dev/null +++ b/maigret_sites_example/mastodon_resolver.py @@ -0,0 +1,29 @@ +# maigret_sites_example/mastodon_resolver.py +import requests + +COMMON_INSTANCES = ["mastodon.social", "fosstodon.org", "mstdn.social", "chaos.social"] + +def resolve_mastodon(nickname, instance_hint=None, timeout=6): + candidates = [] + if "@" in nickname: + # accept nickname@instance + user, inst = nickname.lstrip("@").split("@",1) + candidates.append((user, inst)) + elif instance_hint: + candidates.append((nickname, instance_hint)) + else: + for inst in COMMON_INSTANCES: + candidates.append((nickname, inst)) + + for user, inst in candidates: + url = f"https://{inst}/@{user}" + try: + r = requests.get(url, timeout=timeout, headers={"User-Agent":"maigret/extended"}) + if r.status_code == 200: + return {"status":"found","url":url} + if r.status_code == 404: + continue + except Exception: + continue + return {"status":"not_found"} + diff --git a/maigret_sites_example/newsite.py b/maigret_sites_example/newsite.py new file mode 100644 index 000000000..cae731baa --- /dev/null +++ b/maigret_sites_example/newsite.py @@ -0,0 +1,26 @@ +""" +Simple example checker for NewSite. +Implements check(username, settings=None, logger=None, timeout=6) -> dict +Keep network calls mocked in tests — this file uses requests normally. +""" +import requests + +def check(username, settings=None, logger=None, timeout=6): + url = f"https://newsite.com/{username}" + try: + r = requests.get(url, timeout=timeout, headers={"User-Agent":"maigretexpanded/0.1"}) + except Exception as e: + if logger: + logger.debug("newsite network error: %s", e) + return {"http_status": None, "ids_usernames": {}, "is_similar": False, "parsing_enabled": True, "rank": 999, "url": url} + + found = (r.status_code == 200) + ids = {username: "username"} if found else {} + return { + "http_status": r.status_code, + "ids_usernames": ids, + "is_similar": False, + "parsing_enabled": True, + "rank": 100, + "url": url, + } diff --git a/maigret_sites_example/shodan_checker.py b/maigret_sites_example/shodan_checker.py new file mode 100644 index 000000000..bc0021b9e --- /dev/null +++ b/maigret_sites_example/shodan_checker.py @@ -0,0 +1,69 @@ +""" +Shodan-backed checker (runtime-read API key so tests can monkeypatch). +Opt-in: requires SHODAN_API_KEY env var. Safe/no-op when missing. +""" +import os +import time +from typing import Dict, Any +import requests + +SHODAN_BASE = "https://api.shodan.io" + +def _get_with_backoff(url, params=None, headers=None, timeout=6, retries=2): + backoff = 0.5 + for i in range(retries + 1): + try: + r = requests.get(url, params=params, headers=headers or {}, timeout=timeout) + return r + except requests.RequestException: + if i == retries: + raise + time.sleep(backoff) + backoff *= 2 + +def check(username: str, settings=None, logger=None, timeout=6) -> Dict[str, Any]: + """ + Runtime-checker: reads SHODAN_API_KEY from environment at call-time, + so tests can monkeypatch the environment reliably. + """ + api_key = os.environ.get("SHODAN_API_KEY") + if not api_key: + if logger: + logger.debug("shodan_checker: SHODAN_API_KEY not set, skipping API call.") + return { + "http_status": None, + "ids_usernames": {}, + "is_similar": False, + "parsing_enabled": False, + "rank": 999, + "url": None, + } + + search_url = f"{SHODAN_BASE}/shodan/host/search" + params = {"key": api_key, "query": username} + try: + r = _get_with_backoff(search_url, params=params, timeout=timeout) + except Exception as e: + if logger: + logger.debug("shodan_checker network error: %s", e) + return {"http_status": None, "ids_usernames": {}, "is_similar": False, "parsing_enabled": True, "rank": 999, "url": search_url} + + try: + data = r.json() + except Exception: + data = {} + + # Conservative parsing: mark as found when total > 0 + ids = {} + if isinstance(data, dict) and data.get("total", 0) > 0: + ids = {username: "username"} + + return { + "http_status": getattr(r, "status_code", None), + "ids_usernames": ids, + "is_similar": False, + "parsing_enabled": True, + "rank": 120, + "url": search_url, + "raw": data, + } diff --git a/maigret_sites_example/tiktok_checker.py b/maigret_sites_example/tiktok_checker.py new file mode 100644 index 000000000..cecc21e05 --- /dev/null +++ b/maigret_sites_example/tiktok_checker.py @@ -0,0 +1,45 @@ +# maigret_sites_example/tiktok_checker.py +import re +import requests +from time import sleep + +# small helper for retries + backoff +def http_get_with_backoff(url, headers=None, timeout=10, tries=3): + for i in range(tries): + try: + r = requests.get(url, headers=headers, timeout=timeout) + return r + except Exception: + sleep(1 + i*0.5) + raise + +def check_tiktok(nickname, user_agent=None): + ua = user_agent or "maigret/extended (+https://github.com/dmoney96/maigretexpanded)" + headers = {"User-Agent": ua, "Accept": "text/html,application/json"} + urls = [ + f"https://www.tiktok.com/@{nickname}", + f"https://m.tiktok.com/v/{nickname}", + f"https://vm.tiktok.com/{nickname}/" + ] + for u in urls: + try: + r = http_get_with_backoff(u, headers=headers, timeout=8) + except Exception as e: + return {"status": "error", "error": str(e)} + # direct 404 => not found + if r.status_code == 404: + continue + # JSON response guard + ct = r.headers.get("Content-Type", "") + text = r.text or "" + # Heuristic: page contains JSON with "user" or "uniqueId" + if "uniqueId" in text or re.search(r'"userId"\s*:', text) or "class=\"share-title\"" in text: + return {"status": "found", "url": u} + # Heuristic negative + if "This account is private" in text or "Not Found" in text or r.status_code in (403, 429): + # 403/429 might be rate-limited or blocked — treat as unknown + if r.status_code in (403,429): + return {"status": "unknown", "url": u, "code": r.status_code} + continue + return {"status": "not_found"} + diff --git a/setup_dev.sh b/setup_dev.sh new file mode 100755 index 000000000..1ad6eb5c2 --- /dev/null +++ b/setup_dev.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" + +echo "Recreating .venv..." +rm -rf .venv +python3 -m venv .venv +source .venv/bin/activate + +echo "Upgrading pip and installing dev deps..." +python -m pip install --upgrade pip setuptools wheel +if [ -f requirements-dev.txt ]; then + pip install -r requirements-dev.txt +else + pip install pytest responses requests pytest-httpserver pytest-asyncio anyio pycountry cloudscraper mock +fi + +# optional editable install +pip install -e . || true + +echo "Done. Run: source .venv/bin/activate" +python --version +which python +pip --version diff --git a/sites_extra.json b/sites_extra.json new file mode 100644 index 000000000..b5f89c1a6 --- /dev/null +++ b/sites_extra.json @@ -0,0 +1,10 @@ +{ + "shodan": { + "name": "Shodan", + "url": "https://api.shodan.io/shodan/host/search?query={username}", + "type": "username", + "priority": 120, + "enabled_by_default": false, + "notes": "Opt-in: requires SHODAN_API_KEY env var; returns limited info." + } +} \ No newline at end of file diff --git a/socid_extractor.py b/socid_extractor.py new file mode 100644 index 000000000..4ca9e2ae1 --- /dev/null +++ b/socid_extractor.py @@ -0,0 +1,52 @@ +""" +Dev shim for socid_extractor used by tests. + +Provides: + - __version__ + - extract(text) -> dict-like mapping {id: type} + - mutate_url(url) -> iterable of (url, headers) + - parse(url, cookies_str='', headers=None, timeout=5) -> (page_text, meta_dict) + +This shim intentionally avoids network I/O and returns deterministic results for tests. +""" +__version__ = "0.0.0" + +import re +from typing import Iterable, Tuple, Dict, Any + +def extract(text: str) -> Dict[str, str]: + """ + Return a mapping of extracted ids -> id_type. + For test determinism: if the text contains '/user/' or 'reddit.com/user/' + then return {'':'username'}. Otherwise return empty dict. + """ + if not text: + return {} + # look for reddit-style user paths + m = re.search(r'(?:reddit\.com/)?/?user/([A-Za-z0-9_-]+)', text) + if m: + name = m.group(1) + return {name: "username"} + return {} + +def mutate_url(url: str) -> Iterable[Tuple[str, set]]: + """ + Return additional url/header tuples. Keep empty by default. + """ + return [] + +def parse(url: str, cookies_str: str = "", headers=None, timeout: int = 5) -> Tuple[str, Dict[str, Any]]: + """ + Minimal parse implementation: + - If url contains '/user/' return a small page text that extract() can parse. + - Otherwise return empty page. + Return: (page_text, meta_dict) + """ + # If it's a reddit user URL, produce HTML snippet including the username. + m = re.search(r'/user/([A-Za-z0-9_-]+)', url or "") + if m: + user = m.group(1) + page = f"Profile page for {user} - /user/{user}" + meta = {"url": url} + return (page, meta) + return ("", {"url": url}) diff --git a/tests/test_example_site.py b/tests/test_example_site.py new file mode 100644 index 000000000..b0ab3a653 --- /dev/null +++ b/tests/test_example_site.py @@ -0,0 +1,17 @@ +import responses +from maigret_sites_example.example_site import check + +@responses.activate +def test_found_by_html(): + url = "https://www.example.com/alice" + # HTML fixture that includes a profile header fragment + responses.add(responses.GET, url, body='
Alice
', status=200) + result = check("alice", user_agent="test-agent") + assert result["status"] == "found" + +@responses.activate +def test_not_found_404(): + url = "https://www.example.com/bob" + responses.add(responses.GET, url, body='Not Found', status=404) + result = check("bob", user_agent="test-agent") + assert result["status"] == "not_found" diff --git a/tests/test_mastodon_api_checker.py b/tests/test_mastodon_api_checker.py new file mode 100644 index 000000000..3a5aad536 --- /dev/null +++ b/tests/test_mastodon_api_checker.py @@ -0,0 +1,17 @@ +from unittest.mock import patch +from maigret_sites_example.mastodon_api_checker import check + +def test_mastodon_found(): + fake = {"status":"found","url":"https://mastodon.social/@alice"} + with patch("maigret_sites_example.mastodon_api_resolver.resolve_mastodon_api", return_value=fake): + res = check("alice") + assert res["http_status"] == 200 + assert res["ids_usernames"] == {"alice": "username"} + assert res["parsing_enabled"] is True + +def test_mastodon_not_found(): + fake = {"status":"not_found"} + with patch("maigret_sites_example.mastodon_api_resolver.resolve_mastodon_api", return_value=fake): + res = check("nobody") + assert res["ids_usernames"] == {} + assert res["parsing_enabled"] is False diff --git a/tests/test_mastodon_resolver.py b/tests/test_mastodon_resolver.py new file mode 100644 index 000000000..c9b2fcfeb --- /dev/null +++ b/tests/test_mastodon_resolver.py @@ -0,0 +1,29 @@ +import responses +from maigret_sites_example.mastodon_resolver import resolve_mastodon + +@responses.activate +def test_resolve_with_instance_hint_found(): + instance = "example.social" + url = f"https://{instance}/@alice" + responses.add(responses.GET, url, body="OK", status=200) + res = resolve_mastodon("alice", instance_hint=instance) + assert res["status"] == "found" + assert res["url"] == url + +@responses.activate +def test_resolve_with_common_instances_not_found(): + # Simulate 404 on common instances + responses.add(responses.GET, "https://mastodon.social/@bob", status=404) + responses.add(responses.GET, "https://fosstodon.org/@bob", status=404) + responses.add(responses.GET, "https://mstdn.social/@bob", status=404) + responses.add(responses.GET, "https://chaos.social/@bob", status=404) + res = resolve_mastodon("bob") + assert res["status"] == "not_found" + +@responses.activate +def test_resolve_explicit_nick_at_instance_found(): + url = "https://custom.instance/@charlie" + responses.add(responses.GET, url, status=200) + res = resolve_mastodon("@charlie@custom.instance") + assert res["status"] == "found" + assert res["url"] == url diff --git a/tests/test_newsite.py b/tests/test_newsite.py new file mode 100644 index 000000000..91dd6ad30 --- /dev/null +++ b/tests/test_newsite.py @@ -0,0 +1,16 @@ +from unittest.mock import Mock, patch +from maigret_sites_example.newsite import check + +def test_newsite_found(): + mock_resp = Mock(status_code=200) + with patch("maigret_sites_example.newsite.requests.get", return_value=mock_resp): + res = check("alice") + assert res["http_status"] == 200 + assert res["ids_usernames"] == {"alice": "username"} + +def test_newsite_not_found(): + mock_resp = Mock(status_code=404) + with patch("maigret_sites_example.newsite.requests.get", return_value=mock_resp): + res = check("nobody") + assert res["http_status"] == 404 + assert res["ids_usernames"] == {} diff --git a/tests/test_shodan_checker.py b/tests/test_shodan_checker.py new file mode 100644 index 000000000..099ef1eaa --- /dev/null +++ b/tests/test_shodan_checker.py @@ -0,0 +1,28 @@ +# tests/test_shodan_checker.py +from unittest.mock import patch, Mock +from maigret_sites_example.shodan_checker import check + +def test_shodan_no_key(monkeypatch): + monkeypatch.delenv("SHODAN_API_KEY", raising=False) + res = check("alice") + assert res["ids_usernames"] == {} + assert res["parsing_enabled"] is False + +def test_shodan_found(monkeypatch): + monkeypatch.setenv("SHODAN_API_KEY", "FAKEKEY") + fake_resp = Mock() + fake_resp.status_code = 200 + fake_resp.json.return_value = {"total": 1, "matches": [{"ip_str": "1.2.3.4"}]} + with patch("maigret_sites_example.shodan_checker._get_with_backoff", return_value=fake_resp): + res = check("alice") + assert res["http_status"] == 200 + assert res["ids_usernames"] == {"alice": "username"} + +def test_shodan_not_found(monkeypatch): + monkeypatch.setenv("SHODAN_API_KEY", "FAKEKEY") + fake_resp = Mock() + fake_resp.status_code = 200 + fake_resp.json.return_value = {"total": 0, "matches": []} + with patch("maigret_sites_example.shodan_checker._get_with_backoff", return_value=fake_resp): + res = check("nobody") + assert res["ids_usernames"] == {} diff --git a/tests/test_shodan_integration.py b/tests/test_shodan_integration.py new file mode 100644 index 000000000..a79b979b5 --- /dev/null +++ b/tests/test_shodan_integration.py @@ -0,0 +1,15 @@ +import os +import pytest + +pytestmark = pytest.mark.skipif( + not os.getenv("SHODAN_API_KEY"), + reason="No SHODAN_API_KEY: integration skipped" +) + +from maigret_sites_example.shodan_checker import check + +def test_shodan_integration_runs(): + # a light smoke test to ensure the checker runs with a real key + res = check("github") # arbitrary username to exercise the call + assert isinstance(res, dict) + assert "ids_usernames" in res