Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut

Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:

1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
   Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
   zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
   prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
   Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
   fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
   fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
   die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
   Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.

2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
   ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
   Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
   Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
   Rollback bei Fehlern - install.ps1 bleibt nur noch für die
   Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
   entry.options, nie in configuration.yaml.

Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.

Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:01:01 +02:00
parent 5ff33c92d9
commit fb251154c7
27 changed files with 1268 additions and 82 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Regressionstest für aktualisierung.py's entpacken_pruefen_tauschen().
Testet die Sicherheitslogik des Selbst-Updates komplett isoliert: kein
Gitea-Zugriff, kein Home Assistant, nur ein selbstgebautes Zip gegen einen
Temp-Ordner. Deckt genau die Fälle ab, die vor jedem Tausch abgefangen
werden müssen (siehe Moduldocstring in aktualisierung.py):
- erfolgreicher Tausch inklusive Backup der alten Fassung
- falsche Domain im heruntergeladenen Manifest
- fehlendes version-Feld
- fehlende manifest.json überhaupt
- kaputtes Zip
- der gesuchte Pfad fehlt im Archiv
Aufruf: python3 tests/aktualisierung/test_aktualisierung.py
(braucht das homeassistant-Paket, weil aktualisierung.py es importiert -
in der Integration selbst immer vorhanden, hier z. B. im Test-Container)
"""
import io
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(
0, os.path.join(os.path.dirname(os.path.dirname(_HIER)), "custom_components", "audi_dashboard")
)
import aktualisierung as a # noqa: E402
def _zip_bauen(dateien: dict[str, bytes], wurzel: str = "audi-app-main") -> bytes:
"""Baut ein Zip mit den gegebenen Dateien unter
<wurzel>/custom_components/audi_dashboard/<pfad> - genau die Form, in
der Gitea ein Repo-Archiv liefert."""
puffer = io.BytesIO()
with zipfile.ZipFile(puffer, "w") as archiv:
for pfad, inhalt in dateien.items():
archiv.writestr(f"{wurzel}/custom_components/audi_dashboard/{pfad}", inhalt)
return puffer.getvalue()
_GUELTIGES_MANIFEST = b'{"domain": "audi_dashboard", "version": "2099.1.1.1"}'
class ErfolgreicherTausch(unittest.TestCase):
def setUp(self):
self.arbeitsordner = tempfile.mkdtemp()
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
os.makedirs(self.integration)
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
with open(os.path.join(self.integration, "alte_datei.py"), "wb") as f:
f.write(b"# alte Fassung\n")
def tearDown(self):
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
def test_tauscht_und_sichert_die_alte_fassung(self):
zip_bytes = _zip_bauen({
"manifest.json": _GUELTIGES_MANIFEST,
"neue_datei.py": b"# neue Fassung\n",
"frontend/app.js": b"// panel\n",
})
version = a.entpacken_pruefen_tauschen(
zip_bytes, self.integration, self.staging, self.backup
)
self.assertEqual(version, "2099.1.1.1")
# Live-Ordner trägt jetzt die neue Fassung.
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
self.assertIn(b'"2099.1.1.1"', f.read())
self.assertTrue(os.path.exists(os.path.join(self.integration, "neue_datei.py")))
self.assertTrue(os.path.exists(os.path.join(self.integration, "frontend", "app.js")))
self.assertFalse(os.path.exists(os.path.join(self.integration, "alte_datei.py")))
# Alte Fassung bleibt vollständig und unverändert im Backup-Ordner.
with open(os.path.join(self.backup, "manifest.json"), "rb") as f:
self.assertIn(b'"2026.1.1.1"', f.read())
self.assertTrue(os.path.exists(os.path.join(self.backup, "alte_datei.py")))
# Kein Staging-Rest übrig.
self.assertFalse(os.path.exists(self.staging))
def test_entfernt_pycache_aus_dem_staging_ordner(self):
zip_bytes = _zip_bauen({
"manifest.json": _GUELTIGES_MANIFEST,
"__pycache__/alte.cpython-313.pyc": b"\x00\x00",
})
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self.assertFalse(os.path.exists(os.path.join(self.integration, "__pycache__")))
class AbgelehnteFaelle(unittest.TestCase):
def setUp(self):
self.arbeitsordner = tempfile.mkdtemp()
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
os.makedirs(self.integration)
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
def tearDown(self):
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
def _unangetastet(self):
"""Nach jedem abgelehnten Fall muss der Live-Ordner exakt so
dastehen wie vorher - keiner der Fehlerfälle darf vor dem Tausch
etwas am Live-Ordner verändern (siehe Moduldocstring)."""
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
self.assertIn(b'"2026.1.1.1"', f.read())
self.assertFalse(os.path.exists(self.backup))
def test_lehnt_falsche_domain_ab(self):
zip_bytes = _zip_bauen({
"manifest.json": b'{"domain": "hacs", "version": "2099.1.1.1"}',
})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_fehlendes_version_feld_ab(self):
zip_bytes = _zip_bauen({
"manifest.json": b'{"domain": "audi_dashboard"}',
})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_fehlende_manifest_ab(self):
zip_bytes = _zip_bauen({"irgendeine_datei.py": b"# leer\n"})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_kaputtes_zip_ab(self):
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(
b"das ist kein Zip", self.integration, self.staging, self.backup
)
self._unangetastet()
def test_lehnt_archiv_ohne_gesuchten_pfad_ab(self):
puffer = io.BytesIO()
with zipfile.ZipFile(puffer, "w") as archiv:
archiv.writestr("irgendein-anderes-repo-main/README.md", b"# anderes Projekt\n")
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(
puffer.getvalue(), self.integration, self.staging, self.backup
)
self._unangetastet()
if __name__ == "__main__":
unittest.main()