[ADD] vpn : parcourir les profils AnyConnect au lieu de taper le chemin

Personne ne retient « /opt/cisco/secureclient/vpn/profile ». Le chemin
tapé oblige à le connaître ; le parcours seul n'aide pas quand le fichier
vient d'ailleurs, d'un courriel ou du portail d'un site. Les deux, dans
cet ordre, et la saisie reprend la main dès qu'on sort du parcours sans
rien choisir ou qu'on en rapporte un répertoire.

Le parcours n'est proposé que si l'un des répertoires connus existe :
l'ouvrir sur un chemin absent afficherait une liste vide, ce qui ressemble
à une panne. Vérifié : 7 tests unitaires, dont chacun des replis.

--- EN ---

Nobody remembers "/opt/cisco/secureclient/vpn/profile". A typed path
requires knowing it; browsing alone does not help when the file comes
from elsewhere, from an e-mail or a site's portal. Both, in that order,
and typing takes over as soon as one leaves the browser without choosing
anything, or brings back a directory from it.

Browsing is offered only if one of the known directories exists: opening
it on an absent path would show an empty list, which looks like a fault.
Checked: 7 unit tests, one for each fallback.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-04 08:48:28 -04:00
parent 5aabbe4b87
commit aa14942709
3 changed files with 145 additions and 2 deletions

View file

@ -11779,6 +11779,14 @@ TRANSLATIONS = {
" maintained."
),
},
"Found": {
"fr": "Trouvé",
"en": "Found",
},
"Browse it? (Y/n)": {
"fr": "Le parcourir ? (O/n)",
"en": "Browse it? (Y/n)",
},
"Install it as well? (y/N)": {
"fr": "L'installer aussi ? (o/N)",
"en": "Install it as well? (y/N)",

View file

@ -23,6 +23,13 @@ import os
import click
try:
from script.todo import todo_file_browser
except Exception:
# urwid peut manquer : le parcours devient indisponible, la saisie
# directe reste. Un menu qui ne s'ouvre plus serait pire.
todo_file_browser = None
from script.todo.todo_i18n import t
from script.vpn import anyconnect_xml, presets, profiles
from script.vpn.drivers import DRIVERS, get_driver
@ -65,6 +72,17 @@ PRESET_REPLAYED_NOTE = (
" everything personal is kept."
)
# Répertoires où chercher un profil AnyConnect, dans l'ordre. Les deux
# premiers sont ceux du client de Cisco — le nom a changé entre AnyConnect
# et Secure Client. Les deux derniers parce qu'un site le distribue aussi
# par courriel ou par son portail, et le fichier atterrit alors là.
ANYCONNECT_DIRS = (
"/opt/cisco/secureclient/vpn/profile",
"/opt/cisco/anyconnect/profile",
"~/Downloads",
"~/Téléchargements",
)
# Où le client de Cisco dépose les profils qu'un site distribue. Le dire
# évite d'avoir à le chercher, et c'est le seul endroit où il se trouve
# quand le client graphique a déjà servi sur la machine.
@ -386,6 +404,43 @@ class VpnMenuMixin:
)
self._vpn_edit_profile(seed=seed)
def _vpn_select_xml(self):
"""Chemin du profil `.xml`, "" si l'utilisateur renonce.
Le parcours d'abord, la saisie ensuite, parce que ni l'un ni l'autre
ne suffit : le parcours part des répertoires du client de Cisco et
n'aide pas si le fichier vient d'ailleurs ; le chemin tapé oblige à
le connaître, or personne ne retient
« /opt/cisco/secureclient/vpn/profile ».
Le parcours n'est PROPOSÉ que si un de ces répertoires existe :
l'ouvrir sur un chemin absent afficherait une liste vide, ce qui
ressemble à une panne.
"""
start = next(
(
path
for path in (os.path.expanduser(d) for d in ANYCONNECT_DIRS)
if os.path.isdir(path)
),
"",
)
if todo_file_browser is not None and start:
print(f" {t('Found')} : {start}")
if self._is_yes(input(f"{t('Browse it? (Y/n)')} : ") or "o"):
self._xml_path = ""
browser = todo_file_browser.FileBrowser(
start, self._on_xml_selected
)
browser.run_main_frame()
if self._xml_path and os.path.isfile(self._xml_path):
return self._xml_path
answer = input(f"{t('Path to the .xml profile')} : ").strip()
return os.path.expanduser(answer) if answer else ""
def _on_xml_selected(self, path):
self._xml_path = path
def _vpn_import_anyconnect(self):
"""Transforme un profil AnyConnect (`.xml`) en préréglages.
@ -398,11 +453,11 @@ class VpnMenuMixin:
nomme un établissement.
"""
print(t(ANYCONNECT_LOCATION_NOTE))
path = input(f"{t('Path to the .xml profile')} : ").strip()
path = self._vpn_select_xml()
if not path:
return
try:
found = anyconnect_xml.parse_file(os.path.expanduser(path))
found = anyconnect_xml.parse_file(path)
except anyconnect_xml.ProfileXmlError as error:
print(f"\n✗ {error}")
return

View file

@ -655,6 +655,86 @@ class SsoHelperOffer(MenuBase):
self.assertIn("entretenu", printed)
class ChoosingTheXmlProfile(MenuBase):
"""Le choix du fichier `.xml` : parcours d'abord, saisie ensuite.
Ni l'un ni l'autre ne suffit. Le parcours part des répertoires du
client de Cisco et n'aide pas si le fichier vient d'ailleurs ; le
chemin tapé oblige à le connaître, or personne ne retient
« /opt/cisco/secureclient/vpn/profile ».
"""
def setUp(self):
super().setUp()
self.xml = os.path.join(self.tmp.name, "campus.xml")
with open(self.xml, "w") as fh:
fh.write("<AnyConnectProfile/>")
def browsing(self, chosen, *answers, dirs=None):
"""Déroule `_vpn_select_xml` avec un parcours qui rend `chosen`."""
picked = []
class FauxNavigateur:
def __init__(self, start, callback):
picked.append(start)
self._callback = callback
def run_main_frame(inner):
if chosen is not None:
inner._callback(chosen)
with patch(
"script.todo.vpn_menu.ANYCONNECT_DIRS",
dirs if dirs is not None else (self.tmp.name,),
):
with patch(
"script.todo.vpn_menu.todo_file_browser.FileBrowser",
FauxNavigateur,
):
with self.answering(*answers):
with redirect_stdout(io.StringIO()):
return self.todo._vpn_select_xml(), picked
def test_the_browser_starts_in_the_cisco_directory(self):
path, started = self.browsing(self.xml, "")
self.assertEqual(path, self.xml)
self.assertEqual(started, [self.tmp.name])
def test_declining_the_browser_falls_back_to_typing(self):
path, started = self.browsing(self.xml, "n", self.xml)
self.assertEqual(path, self.xml)
self.assertEqual(started, [], "le parcours ne devait pas s'ouvrir")
def test_leaving_the_browser_empty_falls_back_to_typing(self):
"""On peut sortir du parcours sans rien choisir : la saisie reste."""
path, _ = self.browsing(None, "", self.xml)
self.assertEqual(path, self.xml)
def test_a_path_that_is_not_a_file_does_not_pass_as_chosen(self):
"""Le parcours peut rendre un répertoire : il ne vaut pas fichier,
et la saisie reprend la main."""
path, _ = self.browsing(self.tmp.name, "", self.xml)
self.assertEqual(path, self.xml)
def test_no_cisco_directory_means_no_offer(self):
"""Ouvrir un parcours sur un chemin absent afficherait une liste
vide, ce qui ressemble à une panne. Liste de réponses courte : si
la question était posée, le test lèverait StopIteration."""
path, started = self.browsing(
self.xml, self.xml, dirs=("/nowhere/cisco",)
)
self.assertEqual(path, self.xml)
self.assertEqual(started, [])
def test_a_typed_path_is_expanded(self):
path, _ = self.browsing(None, "n", "~")
self.assertEqual(path, os.path.expanduser("~"))
def test_giving_up_returns_nothing(self):
path, _ = self.browsing(None, "n", "")
self.assertEqual(path, "")
class FromPreset(MenuBase):
"""Le chemin « créer un profil à partir d'un préréglage ».