GDPRScanner/tests/test_app_config.py
StyxX65 526e2b0b78 Fix SMTP auth: settings tab saved wrong config keys
The Settings → E-mailrapport tab (scheduler.js) saved the SMTP username
as `user` and TLS flag as `starttls`, but every backend reader expects
`username`/`use_tls` (routes/email.py). Result: username was always
empty, server.login() was skipped, and the SMTP server rejected the
send — surfacing as a misleading "authentication failed" message even
with a valid App Password. The bug was latent because Graph is preferred
whenever M365 is connected, so the SMTP path was rarely exercised.

- scheduler.js: send/load canonical keys (username, use_tls). The
  send-report modal (scan.js) already used these.
- _load_smtp_config(): normalise legacy user→username / starttls→use_tls
  so configs saved before the fix work without re-entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:25:15 +02:00

288 lines
12 KiB
Python

"""
test_app_config.py — Tests for app_config.py.
Covers:
- LANG loading and key access
- Article 9 keyword detection (_check_special_category)
- Config load/save round-trip
- Admin PIN hash/verify
- Profile CRUD (_profile_save, _profile_get, _profile_delete)
- SMTP password encryption/decryption round-trip
"""
import sys
import json
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
import app_config
# ─────────────────────────────────────────────────────────────────────────────
# 1. i18n
# ─────────────────────────────────────────────────────────────────────────────
class TestLang:
def test_lang_dict_loaded(self):
assert isinstance(app_config.LANG, dict)
assert len(app_config.LANG) > 0
def test_lang_has_lang_code(self):
assert "_lang_code" in app_config.LANG
def test_load_lang_returns_dict(self):
lang = app_config._load_lang()
assert isinstance(lang, dict)
def test_load_lang_forced_en(self):
lang = app_config._load_lang_forced("en")
assert isinstance(lang, dict)
assert len(lang) > 0
def test_load_lang_forced_da(self):
lang = app_config._load_lang_forced("da")
assert isinstance(lang, dict)
assert len(lang) > 0
def test_load_lang_forced_de(self):
lang = app_config._load_lang_forced("de")
assert isinstance(lang, dict)
assert len(lang) > 0
def test_missing_lang_falls_back(self):
# Unknown lang code should fall back without raising
lang = app_config._load_lang_forced("xx")
assert isinstance(lang, dict)
# ─────────────────────────────────────────────────────────────────────────────
# 2. Article 9 keyword detection
# ─────────────────────────────────────────────────────────────────────────────
class TestCheckSpecialCategory:
def _cats(self, text):
cprs = [{"raw": "290472-1234"}]
return app_config._check_special_category(text, cprs)
def test_health_keyword_detected(self):
cats = self._cats("CPR: 290472-1234 har diagnosen diabetes og behandling")
assert "health" in cats
def test_trade_union_keyword_detected(self):
cats = self._cats("CPR: 290472-1234 er fagforeningsmedlem tillidsrepræsentant")
assert "trade_union" in cats
def test_religion_keyword_detected(self):
cats = self._cats("CPR: 290472-1234 kirke konfirmation")
assert "religion" in cats
def test_no_keyword_returns_empty(self):
cats = self._cats("CPR: 290472-1234 bor i Aarhus")
assert cats == []
def test_empty_text_returns_empty(self):
cats = app_config._check_special_category("", [])
assert cats == []
def test_keyword_without_cpr_still_detected(self):
# No CPR — keyword still triggers if no CPR list given
cats = app_config._check_special_category("diagnose sygemelding behandling", [])
assert "health" in cats
def test_returns_sorted_list(self):
cats = self._cats("CPR 290472-1234 diabetes fagforening")
assert cats == sorted(cats)
def test_compiled_keywords_populated(self):
assert len(app_config._compiled_keywords) > 0
def test_keyword_flat_has_entries(self):
assert len(app_config._keyword_flat) > 0
# ─────────────────────────────────────────────────────────────────────────────
# 3. Config load / save
# ─────────────────────────────────────────────────────────────────────────────
class TestConfig:
def test_load_config_returns_dict(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
cfg = app_config._load_config()
assert isinstance(cfg, dict)
def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
app_config._save_config({"client_id": "test-id", "tenant_id": "test-tid"})
cfg = app_config._load_config()
assert cfg["client_id"] == "test-id"
assert cfg["tenant_id"] == "test-tid"
def test_save_config_creates_file(self, tmp_path, monkeypatch):
cfg_path = tmp_path / "config.json"
monkeypatch.setattr(app_config, "_CONFIG_FILE", cfg_path)
app_config._save_config({"x": 1})
assert cfg_path.exists()
def test_load_missing_file_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "nonexistent.json")
cfg = app_config._load_config()
assert cfg == {}
# ─────────────────────────────────────────────────────────────────────────────
# 4. Admin PIN
# ─────────────────────────────────────────────────────────────────────────────
class TestAdminPin:
def test_pin_not_set_initially(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
# Fresh config — no PIN
app_config._save_config({})
assert app_config._admin_pin_is_set() is False
def test_set_and_verify_pin(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
app_config._save_config({})
app_config._set_admin_pin("1234")
assert app_config._verify_admin_pin("1234") is True
def test_wrong_pin_fails(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
app_config._save_config({})
app_config._set_admin_pin("1234")
assert app_config._verify_admin_pin("9999") is False
def test_pin_is_set_after_setting(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_CONFIG_FILE", tmp_path / "config.json")
app_config._save_config({})
app_config._set_admin_pin("5678")
assert app_config._admin_pin_is_set() is True
# ─────────────────────────────────────────────────────────────────────────────
# 5. Profiles
# ─────────────────────────────────────────────────────────────────────────────
class TestProfiles:
@pytest.fixture(autouse=True)
def _isolate(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_SETTINGS_PATH", tmp_path / "settings.json")
def test_profiles_load_returns_list(self):
profiles = app_config._profiles_load()
assert isinstance(profiles, list)
def test_save_and_get_profile(self):
profile = {
"id": "test-uuid-1",
"name": "Test Profile",
"sources": ["email"],
"user_ids": "all",
"options": {},
}
app_config._profile_save(profile)
loaded = app_config._profile_get("Test Profile")
assert loaded is not None
assert loaded["name"] == "Test Profile"
def test_profile_get_by_id(self):
profile = {"id": "uid-42", "name": "By ID", "sources": [], "options": {}}
app_config._profile_save(profile)
loaded = app_config._profile_get("uid-42")
assert loaded is not None
def test_profile_delete(self):
profile = {"id": "del-1", "name": "To Delete", "sources": [], "options": {}}
app_config._profile_save(profile)
deleted = app_config._profile_delete("To Delete")
assert deleted is True
assert app_config._profile_get("To Delete") is None
def test_delete_nonexistent_returns_false(self):
assert app_config._profile_delete("Does Not Exist") is False
def test_profiles_load_after_save(self):
app_config._profile_save({"id": "p1", "name": "P1", "sources": [], "options": {}})
app_config._profile_save({"id": "p2", "name": "P2", "sources": [], "options": {}})
profiles = app_config._profiles_load()
names = [p["name"] for p in profiles]
assert "P1" in names
assert "P2" in names
# ─────────────────────────────────────────────────────────────────────────────
# 6. SMTP password encryption
# ─────────────────────────────────────────────────────────────────────────────
class TestFernet:
@pytest.fixture(autouse=True)
def _isolate(self, tmp_path, monkeypatch):
monkeypatch.setattr(app_config, "_MACHINE_ID_PATH", tmp_path / "machine_id")
def test_encrypt_decrypt_round_trip(self):
fernet = app_config._get_fernet()
if fernet is None:
pytest.skip("cryptography not installed")
plaintext = "my-secret-smtp-password"
encrypted = app_config._encrypt_password(plaintext)
decrypted = app_config._decrypt_password(encrypted)
assert decrypted == plaintext
def test_encrypt_returns_string(self):
fernet = app_config._get_fernet()
if fernet is None:
pytest.skip("cryptography not installed")
result = app_config._encrypt_password("test")
assert isinstance(result, str)
def test_encrypted_differs_from_plaintext(self):
fernet = app_config._get_fernet()
if fernet is None:
pytest.skip("cryptography not installed")
enc = app_config._encrypt_password("password123")
assert enc != "password123"
def test_decrypt_empty_returns_empty(self):
result = app_config._decrypt_password("")
assert result == ""
class TestSmtpConfigLegacyKeys:
"""SMTP config saved by the older settings tab used `user`/`starttls`;
readers expect `username`/`use_tls`. _load_smtp_config must normalise them."""
def test_legacy_keys_normalised_on_load(self, tmp_path, monkeypatch):
import json
p = tmp_path / "smtp.json"
p.write_text(json.dumps({
"host": "smtp.gmail.com", "port": 587,
"user": "netadmin@adm.example.dk", # legacy key
"starttls": True, # legacy key
"from_addr": "netadmin@adm.example.dk",
"recipients": ["a@example.dk"],
}), encoding="utf-8")
monkeypatch.setattr(app_config, "_SMTP_CONFIG_PATH", p)
cfg = app_config._load_smtp_config()
assert cfg["username"] == "netadmin@adm.example.dk"
assert cfg["use_tls"] is True
def test_canonical_keys_take_precedence(self, tmp_path, monkeypatch):
import json
p = tmp_path / "smtp.json"
p.write_text(json.dumps({
"username": "canonical@example.dk",
"user": "legacy@example.dk",
}), encoding="utf-8")
monkeypatch.setattr(app_config, "_SMTP_CONFIG_PATH", p)
cfg = app_config._load_smtp_config()
assert cfg["username"] == "canonical@example.dk"