Files
pyrogram-bridge/tests/test_channel_key.py
T
agent_coder 76f79a1c5f fix(cache): traversal-гард на деструктивные FS-операции миграции + тесты data-loss-guard/reverse-max (ревью #47)
- _is_safe_channel_segment: отвергает имя канала с '/'/'\\'/'..' или не-basename;
  вызывается в начале цикла миграции ДО любого os.rename/_merge_dir_tree/rmtree,
  покрывает оба источника кандидатов (SELECT DISTINCT channel + листинг каталогов).
  Грязный канал (напр. из pre-existing route-traversal) -> skip+warning+failures++,
  старт не падает, деструктивная rename/rmtree не уходит за cache_dir.
- Тесты (мутационно проверены): FS-fail -> SQL пропущен, строки old-cased,
  failures++ (снять continue -> тест краснеет); reverse max(added) (Durov=100/
  durov=300 -> 300); traversal-гард скипает '../Evil'/'a/B' без FS-операций.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 03:56:54 +03:00

351 lines
14 KiB
Python

# flake8: noqa
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
# pylint: disable=redefined-outer-name, line-too-long
# pylance: disable=reportMissingImports, reportMissingModuleSource
"""Tests for the canonical channel key: helper, the three wiring layers, and the
one-shot migration (issue #24, tasks 8-11)."""
import logging
import os
import shutil
import sqlite3
from types import SimpleNamespace
import pytest
import tg_cache
from channel_key import canonical_channel_key
from migrate_channel_keys import migrate_channel_keys_sync
from post_parser import PostParser
# --------------------------------------------------------------------------- #
# Task 11 — unit: canonical_channel_key.
# --------------------------------------------------------------------------- #
def test_canonical_lowercases_username():
assert canonical_channel_key('Durov') == 'durov'
def test_canonical_strips_at_prefix():
assert canonical_channel_key('@durov') == 'durov'
assert canonical_channel_key('@Durov') == 'durov'
def test_canonical_numeric_id_str_preserved():
assert canonical_channel_key('-1001234567890') == '-1001234567890'
def test_canonical_numeric_id_int_preserved():
assert canonical_channel_key(-1001234567890) == '-1001234567890'
def test_canonical_already_lowercase_idempotent():
assert canonical_channel_key('durov') == 'durov'
assert canonical_channel_key(canonical_channel_key('Durov')) == 'durov'
# --------------------------------------------------------------------------- #
# Task 11 — tg_cache path is identical for the two casings.
# --------------------------------------------------------------------------- #
def test_tgcache_path_identical_for_casings(tmp_path, monkeypatch):
monkeypatch.setattr(tg_cache, "CACHE_DIR", str(tmp_path))
p_upper = tg_cache._cache_file_path('Durov', 'history.json')
p_lower = tg_cache._cache_file_path('durov', 'history.json')
p_at = tg_cache._cache_file_path('@DUROV', 'history.json')
assert p_upper == p_lower == p_at
assert os.path.basename(p_upper) == 'durov.history.json'
def test_tgcache_path_numeric_preserved(tmp_path, monkeypatch):
monkeypatch.setattr(tg_cache, "CACHE_DIR", str(tmp_path))
p = tg_cache._cache_file_path('-1001234567890', 'chatinfo.json')
assert os.path.basename(p) == '-1001234567890.chatinfo.json'
# --------------------------------------------------------------------------- #
# Task 11 — get_channel_username lowercases both branches.
# --------------------------------------------------------------------------- #
def _parser():
return PostParser(SimpleNamespace())
def test_get_channel_username_single_lowercased():
parser = _parser()
msg = SimpleNamespace(chat=SimpleNamespace(username='MixedCase', id=1))
assert parser.get_channel_username(msg) == 'mixedcase'
def test_get_channel_username_usernames_list_lowercased():
parser = _parser()
chat = SimpleNamespace(
usernames=[SimpleNamespace(username='MixedCase', active=True)],
username=None,
id=1,
)
assert parser.get_channel_username(SimpleNamespace(chat=chat)) == 'mixedcase'
def test_get_channel_username_numeric_id_unchanged():
parser = _parser()
chat = SimpleNamespace(usernames=None, username=None, id=-1001234567890)
assert parser.get_channel_username(SimpleNamespace(chat=chat)) == '-1001234567890'
# --------------------------------------------------------------------------- #
# Migration helpers.
# --------------------------------------------------------------------------- #
def _make_db(path):
conn = sqlite3.connect(path)
conn.execute(
"""CREATE TABLE media_file_ids (
channel TEXT NOT NULL,
post_id INTEGER NOT NULL,
file_unique_id TEXT NOT NULL,
added REAL NOT NULL,
mime_type TEXT,
PRIMARY KEY (channel, post_id, file_unique_id)
)"""
)
conn.commit()
conn.close()
def _rows(path):
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
rows = [dict(r) for r in conn.execute(
"SELECT channel, post_id, file_unique_id, added, mime_type FROM media_file_ids "
"ORDER BY channel, post_id, file_unique_id")]
conn.close()
return rows
# --------------------------------------------------------------------------- #
# Task 11 (a) — SQL merge of both forms → one row, max(added), non-NULL mime_type.
# --------------------------------------------------------------------------- #
def test_migration_sql_merge(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
# Old-cased row (higher added, NULL mime) and lowercase twin (lower added, has mime).
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 200.0, None))
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('durov', 5, 'fid', 100.0, 'image/jpeg'))
conn.commit()
conn.close()
migrate_channel_keys_sync(db, str(cache))
rows = _rows(db)
assert len(rows) == 1
r = rows[0]
assert r['channel'] == 'durov'
assert r['added'] == 200.0 # max(added)
assert r['mime_type'] == 'image/jpeg' # non-NULL preferred
def test_migration_sql_rekey_no_twin(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 7, 'fid', 50.0, 'video/mp4'))
conn.commit()
conn.close()
migrate_channel_keys_sync(db, str(cache))
rows = _rows(db)
assert len(rows) == 1
assert rows[0]['channel'] == 'durov'
assert rows[0]['mime_type'] == 'video/mp4'
# --------------------------------------------------------------------------- #
# Task 11 (b) — samefile guard → no-op, data intact (case-insensitive FS simulated).
# --------------------------------------------------------------------------- #
def test_migration_samefile_guard_no_data_loss(tmp_path, monkeypatch):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
# Single dir 'Durov' with a file; on a case-insensitive FS 'durov' would be the same dir.
src = cache / "Durov" / "5"
src.mkdir(parents=True)
(src / "fid").write_bytes(b"payload")
# Simulate a case-insensitive FS: any existence check on the lowercase twin resolves,
# and samefile reports src == dst so the FS step must be a pure no-op.
real_exists = os.path.exists
def fake_exists(p):
if os.path.basename(p) == 'durov':
return True
return real_exists(p)
monkeypatch.setattr(os.path, "exists", fake_exists)
monkeypatch.setattr(os.path, "samefile", lambda a, b: True)
migrate_channel_keys_sync(db, str(cache))
# Data intact: the original file must still be present and unchanged.
assert (cache / "Durov" / "5" / "fid").read_bytes() == b"payload"
# --------------------------------------------------------------------------- #
# Task 11 (c) — merge of two genuinely different dirs → combined, target wins.
# --------------------------------------------------------------------------- #
def test_migration_fs_merge_different_dirs(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
# Old-cased tree.
(cache / "Durov" / "5").mkdir(parents=True)
(cache / "Durov" / "5" / "shared").write_bytes(b"OLD") # collides -> dst wins
(cache / "Durov" / "5" / "only_old").write_bytes(b"OLD_ONLY")
# Canonical tree.
(cache / "durov" / "5").mkdir(parents=True)
(cache / "durov" / "5" / "shared").write_bytes(b"NEW") # existing dst wins
(cache / "durov" / "6").mkdir(parents=True)
(cache / "durov" / "6" / "only_new").write_bytes(b"NEW_ONLY")
migrate_channel_keys_sync(db, str(cache))
# Old dir gone, everything merged under 'durov'.
assert not (cache / "Durov").exists()
assert (cache / "durov" / "5" / "shared").read_bytes() == b"NEW" # target wins
assert (cache / "durov" / "5" / "only_old").read_bytes() == b"OLD_ONLY" # brought over
assert (cache / "durov" / "6" / "only_new").read_bytes() == b"NEW_ONLY" # untouched
def test_migration_fs_rename_when_dst_missing(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
(cache / "Durov" / "5").mkdir(parents=True)
(cache / "Durov" / "5" / "fid").write_bytes(b"data")
migrate_channel_keys_sync(db, str(cache))
assert not (cache / "Durov").exists()
assert (cache / "durov" / "5" / "fid").read_bytes() == b"data"
# --------------------------------------------------------------------------- #
# Task 11 (d) — re-run is a no-op.
# --------------------------------------------------------------------------- #
def test_migration_rerun_noop(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 200.0, None))
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('durov', 5, 'fid', 100.0, 'image/jpeg'))
conn.commit()
conn.close()
(cache / "Durov" / "5").mkdir(parents=True)
(cache / "Durov" / "5" / "fid").write_bytes(b"data")
migrate_channel_keys_sync(db, str(cache))
first = _rows(db)
# Second run: nothing left to migrate.
migrate_channel_keys_sync(db, str(cache))
second = _rows(db)
assert first == second
assert all(r['channel'] == r['channel'].lower() for r in second)
assert (cache / "durov" / "5" / "fid").read_bytes() == b"data"
# --------------------------------------------------------------------------- #
# Data-loss guard — an FS-step failure must SKIP the SQL step (rows stay old-cased).
# Has teeth: fails if the `continue` after the FS OSError is removed.
# --------------------------------------------------------------------------- #
def test_migration_fs_failure_skips_sql(tmp_path, monkeypatch, caplog):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 100.0, 'image/jpeg'))
conn.commit()
conn.close()
# Old-cased dir with no lowercase twin → the migration takes the os.rename path.
(cache / "Durov" / "5").mkdir(parents=True)
(cache / "Durov" / "5" / "fid").write_bytes(b"data")
def boom(*_a, **_k):
raise OSError("disk on fire")
monkeypatch.setattr(os, "rename", boom)
with caplog.at_level(logging.INFO):
migrate_channel_keys_sync(db, str(cache)) # (a) must NOT crash startup
# (b) SQL step skipped: the row is still OLD-cased.
rows = _rows(db)
assert len(rows) == 1
assert rows[0]['channel'] == 'Durov'
# (c) failures counter increased (surfaced in the summary log).
assert "failures 1" in caplog.text
# --------------------------------------------------------------------------- #
# Reverse-direction max(added): lowercase twin has the LARGER `added` → it wins.
# Proves max() is not one-directional ("old always wins").
# --------------------------------------------------------------------------- #
def test_migration_sql_merge_reverse_max(tmp_path):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
# OLD-cased added=100 (SMALLER), lowercase twin added=300 (LARGER).
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 100.0, 'image/jpeg'))
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('durov', 5, 'fid', 300.0, None))
conn.commit()
conn.close()
migrate_channel_keys_sync(db, str(cache))
rows = _rows(db)
assert len(rows) == 1
assert rows[0]['channel'] == 'durov'
assert rows[0]['added'] == 300.0 # max(added): the twin's LARGER value survives
assert rows[0]['mime_type'] == 'image/jpeg' # non-NULL preferred (from the old row)
# --------------------------------------------------------------------------- #
# Traversal guard — a DB channel name with '/' or '..' is SKIPPED before any FS op.
# Has teeth: fails if the _is_safe_channel_segment guard is removed.
# --------------------------------------------------------------------------- #
def test_migration_traversal_guard_skips_dirty_channel(tmp_path, monkeypatch):
db = str(tmp_path / "m.db")
cache = tmp_path / "cache"
cache.mkdir()
_make_db(db)
conn = sqlite3.connect(db)
# Dirty mixed-case channel names → become migration candidates via the DB query.
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('../Evil', 5, 'fid', 100.0, None))
conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('a/B', 6, 'fid', 100.0, None))
conn.commit()
conn.close()
# Spy: no destructive FS op may run (all dirty candidates rejected before the FS step).
called = []
monkeypatch.setattr(os, "rename", lambda *a, **k: called.append(('rename', a)))
monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: called.append(('rmtree', a)))
migrate_channel_keys_sync(db, str(cache)) # must NOT crash
assert called == [] # nothing renamed/removed → no op escaped cache_dir
# Rejected channels are left un-migrated (rows keep their original dirty names).
channels = {r['channel'] for r in _rows(db)}
assert channels == {'../Evil', 'a/B'}