File size: 12,428 Bytes
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
65651b1
 
2e79f82
de342dc
 
2e79f82
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
65651b1
 
2e79f82
de342dc
65651b1
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""Settings from the environment, the deployment files, and that nothing the owner needs is left undocumented."""
import asyncio
import py_compile
import re
import socket
import tempfile
from pathlib import Path

import pytest

from app.config import DEFAULT_ERROR_LOG_CHAT_ID, Config, default_session_path, load_config
from app.health import start_health_server
from bot import COMMANDS, secrets_of

from .harness import App

ROOT = Path(__file__).resolve().parent.parent
REQUIRED = {"API_ID": "12345", "API_HASH": "abcdef0123456789", "BOT_TOKEN": "123456:ABCdefGHIjklMNOpqrSTUvwxYZ", "OWNER_IDS": "1001484109"}


def env_names() -> list:
    """Every environment variable app/config.py reads."""
    src = (ROOT / "app" / "config.py").read_text()
    names = set(re.findall(r'(?:getenv|_ints|_bool|_chat_id|_float|_whole)\(\s*"([A-Z][A-Z0-9_]+)"', src))
    names |= set(re.findall(r'environ\[\s*"([A-Z][A-Z0-9_]+)"', src))
    return sorted(names)


@pytest.fixture
def env(monkeypatch):
    for k in env_names():
        monkeypatch.delenv(k, raising=False)
    for k, v in REQUIRED.items():
        monkeypatch.setenv(k, v)
    return monkeypatch


# ----------------------------------------------------------------------------------------- defaults
def test_the_names_the_test_looks_for_are_all_found():
    names = env_names()
    for expected in ("API_ID", "BOT_TOKEN", "OWNER_IDS", "ERROR_LOG_CHAT_ID", "USERBOT_SESSION", "USERBOT_KEEP_ADMIN", "SESSION_PATH", "PORT"):
        assert expected in names, names


def test_defaults(env):
    cfg = load_config()
    assert cfg.error_log_chat_id == DEFAULT_ERROR_LOG_CHAT_ID == -1002525172451
    assert cfg.userbot_session == "" and cfg.userbot_keep_admin is False
    assert cfg.owners == frozenset({1001484109}) and cfg.admins == frozenset()
    assert cfg.edit_delay == 1.2 and cfg.replace_typed_links is True and cfg.port == 0
    assert cfg.database_url.startswith("sqlite")


@pytest.mark.parametrize(
    "raw,expected",
    [
        (None, -1002525172451),  # not set at all -> the default chat
        ("", -1002525172451),
        ("   ", -1002525172451),
        ("-1001234567890", -1001234567890),
        (" -1001234567890 ", -1001234567890),
        ("123456789", 123456789),  # a private chat with a person
        ("0", None),
        ("off", None),
        ("OFF", None),
        ("None", None),
        ("false", None),
        ("no", None),
        ("disabled", None),
    ],
)
def test_error_log_chat_id(env, raw, expected):
    if raw is not None:
        env.setenv("ERROR_LOG_CHAT_ID", raw)
    assert load_config().error_log_chat_id == expected


@pytest.mark.parametrize("raw", ["abc", "-100 123", "@mychannel", "1.5"])
def test_a_bad_error_log_chat_id_is_refused_with_its_name(env, raw):
    env.setenv("ERROR_LOG_CHAT_ID", raw)
    with pytest.raises(SystemExit) as e:
        load_config()
    assert "ERROR_LOG_CHAT_ID" in str(e.value) and repr(raw) in str(e.value)


def test_userbot_settings(env):
    env.setenv("USERBOT_SESSION", "  1BVtsOKwBu7abc  ")
    env.setenv("USERBOT_KEEP_ADMIN", "true")
    cfg = load_config()
    assert cfg.userbot_session == "1BVtsOKwBu7abc" and cfg.userbot_keep_admin is True
    for v, want in [("1", True), ("yes", True), ("ON", True), ("false", False), ("0", False), ("", False), ("nonsense", False)]:
        env.setenv("USERBOT_KEEP_ADMIN", v)
        assert load_config().userbot_keep_admin is want, v


def test_missing_settings_are_listed(env):
    env.delenv("API_HASH")
    env.delenv("OWNER_IDS")
    with pytest.raises(SystemExit) as e:
        load_config()
    assert "API_HASH" in str(e.value) and "OWNER_IDS" in str(e.value) and "API_ID" not in str(e.value)
    env.setenv("OWNER_IDS", "not a number")
    env.setenv("API_HASH", "x")
    with pytest.raises(SystemExit) as e:
        load_config()
    assert "OWNER_IDS" in str(e.value)


def test_api_id_must_be_a_number(env):
    env.setenv("API_ID", "twelve")
    with pytest.raises(SystemExit) as e:
        load_config()
    assert "API_ID must be a number" in str(e.value)


def test_service_message_and_sync_settings(env):
    cfg = load_config()
    assert cfg.delete_service_messages is True and cfg.sync_interval_minutes == 60  # both are on unless somebody says otherwise
    env.setenv("DELETE_SERVICE_MESSAGES", "false")
    env.setenv("SYNC_INTERVAL_MINUTES", "15")
    cfg = load_config()
    assert cfg.delete_service_messages is False and cfg.sync_interval_minutes == 15
    env.setenv("DELETE_SERVICE_MESSAGES", "yes")
    assert load_config().delete_service_messages is True


def test_the_older_posts_of_a_channel_are_imported_unless_somebody_says_otherwise(env):
    assert load_config().import_old_posts is True
    env.setenv("IMPORT_OLD_POSTS", "false")
    assert load_config().import_old_posts is False
    env.setenv("IMPORT_OLD_POSTS", "")
    assert load_config().import_old_posts is True


@pytest.mark.parametrize(
    "raw,expected",
    [("0", 0), ("-5", 0), ("30", 30), ("2.9", 2), ("  45 ", 45), ("", 60), ("abc", 60), ("inf", 60), ("-inf", 60), ("nan", 60)],
)
def test_sync_interval_minutes_never_stops_the_bot_from_starting(env, raw, expected):
    env.setenv("SYNC_INTERVAL_MINUTES", raw)
    assert load_config().sync_interval_minutes == expected  # 0 = no automatic checks; nonsense = the default


def test_edit_delay_has_a_floor(env):
    env.setenv("EDIT_DELAY", "0.1")
    assert load_config().edit_delay == 0.3
    env.setenv("EDIT_DELAY", "banana")
    assert load_config().edit_delay == 1.2
    env.setenv("EDIT_DELAY", "2.5")
    assert load_config().edit_delay == 2.5


def test_the_session_is_not_kept_in_the_app_folder_by_default(env):
    path = Path(load_config().session_path).resolve()
    assert str(path).startswith(str(Path(tempfile.gettempdir()).resolve()))
    assert ROOT not in path.parents  # an app folder that a web server publishes must not contain the login
    assert Path(default_session_path()).name == "bot"
    env.setenv("SESSION_PATH", "data/bot")
    assert load_config().session_path == "data/bot"  # still possible when somebody wants it


# ------------------------------------------------------------------------------ secrets of the bot
def test_every_secret_is_collected_for_redaction():
    cfg = Config(
        api_id=1, api_hash="HASHHASHHASH", bot_token="123:TOKEN-TOKEN", owners=frozenset({1}), admins=frozenset(),
        database_url="postgresql://user:p%40ss%2Fword@host.example/db?sslmode=require", db_pool="null", session_path="x",
        session_string="SESSIONSTRINGTEXT", edit_delay=1.0, replace_typed_links=True, port=0, log_level="INFO",
        userbot_session="USERBOTSESSIONTEXT",
    )
    found = secrets_of(cfg)
    for s in ("HASHHASHHASH", "123:TOKEN-TOKEN", "SESSIONSTRINGTEXT", "USERBOTSESSIONTEXT", "p%40ss%2Fword", "p@ss/word"):
        assert s in found, (s, found)
    assert "" not in found
    plain = Config(**{**cfg.__dict__, "database_url": "sqlite+aiosqlite:///data/bot.db", "session_string": "", "userbot_session": ""})
    assert set(secrets_of(plain)) == {"123:TOKEN-TOKEN", "HASHHASHHASH"}  # nothing else to hide, and no crash on SQLite


# ------------------------------------------------------------------------------------- deployment files
def test_dockerfile_is_exactly_the_one_that_was_asked_for():
    lines = [ln.rstrip() for ln in (ROOT / "Dockerfile").read_text().splitlines() if ln.strip()]
    assert lines == [
        "FROM python:3.12-slim",
        "WORKDIR /app",
        "ENV PYTHONUNBUFFERED=1",
        "COPY requirements.txt .",
        "RUN pip install --no-cache-dir -r requirements.txt",
        "COPY . .",
        "CMD python -m http.server 7860 & python bot.py",
    ]


def test_github_workflow_syncs_to_the_space_without_a_hard_coded_token():
    text = (ROOT / ".github" / "workflows" / "hf-sync.yml").read_text()
    assert "huggingface.co/spaces/Mega-Evolutions/ButtonBot" in text
    assert "${{ secrets.HF_TOKEN }}" in text and "branches: [main]" in text and "workflow_dispatch" in text
    assert "--exclude='README.md'" in text and "--exclude='.github'" in text  # the Space keeps its own README
    assert not re.search(r"hf_[A-Za-z0-9]{20,}", text)  # no real token pasted in
    yaml = pytest.importorskip("yaml")
    doc = yaml.safe_load(text)
    assert doc["jobs"]["sync-to-hub"]["steps"][1]["env"]["HF_TOKEN"] == "${{ secrets.HF_TOKEN }}"


def test_nothing_secret_can_end_up_in_the_image_or_the_repository():
    docker = (ROOT / ".dockerignore").read_text().split()
    for entry in (".env", "*.session", "data/*", "tests"):
        assert entry in docker, entry
    git = (ROOT / ".gitignore").read_text().split()
    for entry in (".env", "*.session"):
        assert entry in git, entry


def test_env_example_documents_every_setting():
    text = (ROOT / ".env.example").read_text()
    listed = set(re.findall(r"^#?\s*([A-Z][A-Z0-9_]+)=", text, re.M))
    missing = [n for n in env_names() if n not in listed]
    assert not missing, f".env.example does not mention: {missing}"
    unknown = sorted(listed - set(env_names()))
    assert not unknown, f".env.example mentions settings the bot does not read: {unknown}"
    assert re.search(r"^ERROR_LOG_CHAT_ID=-1002525172451$", text, re.M)


def test_helper_scripts_compile_and_the_userbot_one_stands_alone():
    for name in ("make_userbot_session.py", "make_session.py", "bot.py"):
        py_compile.compile(str(ROOT / name), doraise=True)
    src = (ROOT / "make_userbot_session.py").read_text()
    assert "from app" not in src and "import app" not in src  # runs on its own computer with only telethon installed
    assert "StringSession" in src and "USERBOT_SESSION=" in src


def test_the_testing_commands_are_gone_everywhere():
    names = [c for c, _ in COMMANDS]
    assert "testedit" not in names and "selftest" not in names
    assert {"new", "posts", "repost", "replace", "undo", "userbot", "help"} <= set(names)
    assert {"autopost", "broadcast", "handleswap", "shift", "sync", "export", "import"} <= set(names)
    assert "fixlinks" not in names
    assert "testerror" not in names
    for path in list((ROOT / "app").rglob("*.py")) + [ROOT / "bot.py"]:
        text = path.read_text().lower()
        assert "testedit" not in text and "selftest" not in text and "testerror" not in text, path


def test_every_menu_command_has_a_handler(tmp_path):
    async def go():
        app = App(tmp_path)
        await app.start()
        patterns = [b.pattern for b, _ in app.tg.handlers if getattr(b, "pattern", None)]
        for name, _ in COMMANDS:
            assert any(p(f"/{name}") for p in patterns), f"/{name} is in the menu but nothing answers it"
        await app.db.close()

    asyncio.run(go())


def test_the_help_text_lists_the_commands_that_exist():
    from app.handlers.basic import HELP

    for name in (
        "new", "posts", "repost", "replace", "undo", "userbot", "export", "import", "addchannel", "channels",
        "autopost", "broadcast", "handleswap", "shift", "sync",
    ):
        assert f"/{name}" in HELP, name
    assert "--channel" not in HELP and "/testerror" not in HELP and "/fixlinks" not in HELP and "--clean" not in HELP


# ------------------------------------------------------------------------------------------ health port
def free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def test_health_endpoint_answers_ok():
    async def go():
        port = free_port()
        server = await start_health_server(port)
        assert server is not None
        reader, writer = await asyncio.open_connection("127.0.0.1", port)
        writer.write(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")
        await writer.drain()
        data = await asyncio.wait_for(reader.read(), 5)
        assert data.startswith(b"HTTP/1.1 200 OK") and data.endswith(b"ok")
        writer.close()
        server.close()
        await server.wait_closed()

    asyncio.run(go())


def test_a_port_that_is_already_taken_does_not_stop_the_bot():
    """The Dockerfile starts `python -m http.server 7860` first; if PORT=7860 is set too, the bot must carry on."""

    async def go():
        busy = socket.socket()
        busy.bind(("0.0.0.0", 0))
        busy.listen()
        try:
            assert await start_health_server(busy.getsockname()[1]) is None
        finally:
            busy.close()

    asyncio.run(go())