Author SHA1 Message Date
Codex 75dd78ee1e fix(blog): acquitter les GUID apres livraison XMPP 2026-09-12 15:33:59 +02:00
11 changed files with 185 additions and 82 deletions
-2
View File
@@ -23,8 +23,6 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_AUTH_MODE=qr_token # PRONOTE_AUTH_MODE=qr_token
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json # PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
# PRONOTE_QR_PIN=1234 # PRONOTE_QR_PIN=1234
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
# PRONOTE_ACCOUNT_PIN=
# --- CalDAV --- # --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
+6 -5
View File
@@ -66,11 +66,12 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés. Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
exporté depuis le site web Pronote. Si le compte exige un second facteur, l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs. masquerait une livraison non confirmée.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire `.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
-14
View File
@@ -47,7 +47,6 @@ class PronoteSettings(BaseSettings):
auth_mode: Literal["password", "qr_token"] = "password" auth_mode: Literal["password", "qr_token"] = "password"
qr_code_file: str | None = None qr_code_file: str | None = None
qr_pin: SecretStr | None = None qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None
@field_serializer("ical_url") @field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None: def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
@@ -73,18 +72,6 @@ class PronoteSettings(BaseSettings):
return None return None
return "**********" return "**********"
@field_serializer("account_pin")
def _serialize_account_pin(self, value: SecretStr | None) -> str | None:
"""Masque le PIN du compte lors de la sérialisation.
:param value: Valeur du PIN de second facteur du compte.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings): class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination. """Paramètres d'accès au serveur CalDAV de destination.
@@ -341,7 +328,6 @@ class Settings(BaseSettings):
self.pronote.ical_url, self.pronote.ical_url,
self.pronote.password, self.pronote.password,
self.pronote.qr_pin, self.pronote.qr_pin,
self.pronote.account_pin,
self.caldav.url, self.caldav.url,
self.caldav.password, self.caldav.password,
self.xmpp.password, self.xmpp.password,
+7 -2
View File
@@ -230,11 +230,13 @@ class PipelineRunner:
data = normalize_step(fetched, generated_at=now) data = normalize_step(fetched, generated_at=now)
try: try:
blog_articles = fetch_blog_step(self._blog_client, self._blog_state) blog_result = fetch_blog_step(self._blog_client, self._blog_state)
blog_articles = list(blog_result.articles)
except PipelineCriticalError: except PipelineCriticalError:
raise raise
except Exception as exc: except Exception as exc:
self._warn("fetch_blog", self._redact(exc)) self._warn("fetch_blog", self._redact(exc))
blog_result = None
blog_articles = [] blog_articles = []
try: try:
@@ -290,8 +292,11 @@ class PipelineRunner:
) )
if self._channel is not None and not self._dry_run: if self._channel is not None and not self._dry_run:
try: try:
if not send_step(self._channel, message): delivered = send_step(self._channel, message)
if not delivered:
self._warn("send", "Le canal XMPP a refusé l'envoi") self._warn("send", "Le canal XMPP a refusé l'envoi")
elif blog_result is not None and self._blog_state is not None:
self._blog_state.acknowledge(blog_result)
except PipelineCriticalError: except PipelineCriticalError:
raise raise
except Exception as exc: except Exception as exc:
+13 -9
View File
@@ -2,23 +2,28 @@
from __future__ import annotations from __future__ import annotations
from pronote_sync.models.blog import BlogArticle from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]: def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
"""Récupère les articles RSS nouveaux en conservant l'état du client. """Récupère les articles RSS nouveaux sans les acquitter.
L'état des GUID est acquitté séparément par le pipeline après confirmation
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
peuvent être conservés immédiatement, car aucune livraison n'est alors en
attente.
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé. :param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
:param state: État de déduplication et de cache HTTP associé au run. :param state: État de déduplication et de cache HTTP associé au run.
:return: Nouveaux articles du blog. :return: Résultat de récupération, incluant les métadonnées de cache.
:rtype: list[BlogArticle] :rtype: BlogRSSFetchResult
:raises RuntimeError: Si la récupération RSS injectée échoue. :raises RuntimeError: Si la récupération RSS injectée échoue.
""" """
if client is None or state is None: if client is None or state is None:
return [] return BlogRSSFetchResult()
try: try:
etag, last_modified = state.get_cache_headers() etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse( result = client.fetch_and_parse(
@@ -26,9 +31,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
) )
if result.error is not None: if result.error is not None:
raise RuntimeError(result.error) from None raise RuntimeError(result.error) from None
if not result.not_modified: if not result.not_modified and not result.articles:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified) state.update_cache_headers(result.etag, result.last_modified)
return list(result.articles) return result
except Exception as exc: except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
+17
View File
@@ -19,6 +19,7 @@ import logging
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.utils.redaction import redact_exception, redact_secrets from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -156,6 +157,22 @@ class BlogRSSState:
self._known_guids.update(new_guids) self._known_guids.update(new_guids)
self._save() self._save()
def acknowledge(self, result: BlogRSSFetchResult) -> None:
"""Acquitte une récupération RSS après sa livraison confirmée.
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
article dont la livraison a échoué reste récupérable à l'exécution
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
:param result: Résultat RSS livré avec succès.
"""
if result.not_modified:
return
self._known_guids.update(article.id for article in result.articles)
self._etag = result.etag
self._last_modified = result.last_modified
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+1 -15
View File
@@ -119,9 +119,6 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
# PIN QR # PIN QR
if settings.qr_pin is not None: if settings.qr_pin is not None:
secrets.append(settings.qr_pin.get_secret_value()) secrets.append(settings.qr_pin.get_secret_value())
# PIN de second facteur du compte
if settings.account_pin is not None:
secrets.append(settings.account_pin.get_secret_value())
# Contenu du fichier QR (jeton, login, url) # Contenu du fichier QR (jeton, login, url)
if settings.qr_code_file is not None: if settings.qr_code_file is not None:
try: try:
@@ -340,12 +337,7 @@ class PronoteClient:
creds = self._auth_state.load() creds = self._auth_state.load()
if creds is not None: if creds is not None:
try: try:
account_pin = ( client = client_class.token_login(**creds)
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.token_login(**creds, account_pin=account_pin)
if client.logged_in: if client.logged_in:
self._client = client self._client = client
self._persist_credentials() self._persist_credentials()
@@ -429,16 +421,10 @@ class PronoteClient:
app_uuid = f"pronote-sync-{uuid4().hex}" app_uuid = f"pronote-sync-{uuid4().hex}"
try: try:
account_pin = (
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.qrcode_login( client = client_class.qrcode_login(
qr_code=qr_data, qr_code=qr_data,
pin=pin_value, pin=pin_value,
uuid=app_uuid, uuid=app_uuid,
account_pin=account_pin,
) )
except Exception as exc: except Exception as exc:
logger.error( logger.error(
+104
View File
@@ -1040,6 +1040,110 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
assert xmpp_message.external_info.blog_articles[0].title == "Test Article" assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
@pytest.mark.parametrize(
("channel_kind", "dry_run", "should_acknowledge"),
[
("success", False, True),
("false", False, False),
("exception", False, False),
("none", False, False),
("success", True, False),
],
)
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
pipeline_inputs: tuple[Lesson, Homework],
tmp_path: Any,
channel_kind: str,
dry_run: bool,
should_acknowledge: bool,
) -> None:
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
:param pipeline_inputs: Données Pronote de test.
:param tmp_path: Répertoire temporaire pour l'état RSS.
:param channel_kind: Comportement du canal XMPP simulé.
:param dry_run: Active ou non le mode simulation.
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
"""
lesson, homework = pipeline_inputs
calls: list[str] = []
state_file = tmp_path / "blog-state.json"
class SuccessfulBlogClient:
"""Client RSS renvoyant un article non encore livré."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Retourne un article et des en-têtes de cache déterministes.
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
:param etag: ETag mémorisé, ignoré dans ce faux client.
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
:return: Résultat RSS avec un article à livrer.
:rtype: BlogRSSFetchResult
"""
del known_guids, etag, last_modified
return BlogRSSFetchResult(
articles=(
BlogArticle(
id="article-to-deliver",
title="Article à livrer",
url="https://example.com/article-to-deliver",
published_at=datetime(2026, 9, 8, 12, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
etag="etag-after-delivery",
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
)
channel: Any
if channel_kind == "success":
channel = StubChannel(calls)
elif channel_kind == "false":
channel = FailingChannel()
elif channel_kind == "exception":
channel = ExceptionalChannel()
else:
channel = None
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
blog_state=BlogRSSState(state_file),
channel=channel,
dry_run=dry_run,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
if should_acknowledge:
acknowledged_state = BlogRSSState(state_file)
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
assert acknowledged_state.get_cache_headers() == (
"etag-after-delivery",
"Tue, 08 Sep 2026 12:00:00 GMT",
)
else:
assert not state_file.exists()
if channel_kind in {"false", "exception"}:
assert any(error.step == "send" for error in errors)
def test_runner_secret_redaction_in_pipeline_errors( def test_runner_secret_redaction_in_pipeline_errors(
pipeline_inputs: tuple[Lesson, Homework], pipeline_inputs: tuple[Lesson, Homework],
) -> None: ) -> None:
+35
View File
@@ -14,11 +14,14 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.blog.state import BlogRSSState
@@ -116,6 +119,38 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
assert state_file.read_text(encoding="utf-8") == original_content assert state_file.read_text(encoding="utf-8") == original_content
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
"""Vérifie l'acquittement atomique après une livraison confirmée.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
article = BlogArticle(
id="guid-1",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="etag-1",
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
)
)
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
def test_state_load_persisted_guids(tmp_path: Path) -> None: def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance. """Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
-17
View File
@@ -207,21 +207,4 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
assert "**********" in repr(settings.pronote.qr_pin) assert "**********" in repr(settings.pronote.qr_pin)
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
settings = load_settings()
assert isinstance(settings.pronote.account_pin, SecretStr)
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
assert settings.pronote.account_pin in settings.redaction_secrets()
assert "account-pin-42" not in str(settings)
assert "account-pin-42" not in settings.model_dump_json()
assert "**********" in settings.model_dump_json()
# Ensure trailing newline # Ensure trailing newline
+2 -18
View File
@@ -674,10 +674,8 @@ def test_connect_password_mode_unchanged(
assert mock_client_class.call_count == 1 assert mock_client_class.call_count == 1
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_with_persisted_creds( def test_connect_qr_token_with_persisted_creds(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie le login par token persisté en mode qr_token. """Vérifie le login par token persisté en mode qr_token.
@@ -685,7 +683,6 @@ def test_connect_qr_token_with_persisted_creds(
``token_login`` et le token rotate est resauvegardé. ``token_login`` et le token rotate est resauvegardé.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
creds = { creds = {
@@ -710,29 +707,23 @@ def test_connect_qr_token_with_persisted_creds(
ent=None, ent=None,
account_type="parent", account_type="parent",
auth_mode="qr_token", auth_mode="qr_token",
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
assert connected is mock_client assert connected is mock_client
pronotepy.ParentClient.token_login.assert_called_once_with( # type: ignore[attr-defined] pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
**creds, account_pin=account_pin
)
auth_state.save.assert_called_once_with(rotated_creds) auth_state.save.assert_called_once_with(rotated_creds)
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_no_creds_with_qr_code( def test_connect_qr_token_no_creds_with_qr_code(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
tmp_path: Path, tmp_path: Path,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté. """Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test. :param tmp_path: Répertoire temporaire de test.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
qr_file = tmp_path / "qr_code.json" qr_file = tmp_path / "qr_code.json"
@@ -769,7 +760,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
auth_mode="qr_token", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"), qr_pin=SecretStr("123456"),
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
@@ -779,7 +769,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
qrcode_login.assert_called_once() # type: ignore[attr-defined] qrcode_login.assert_called_once() # type: ignore[attr-defined]
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined] kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
assert kwargs["pin"] == "123456" assert kwargs["pin"] == "123456"
assert kwargs["account_pin"] == account_pin
assert kwargs["qr_code"] == { assert kwargs["qr_code"] == {
"login": "testuser", "login": "testuser",
"jeton": "qr-jeton", "jeton": "qr-jeton",
@@ -1178,7 +1167,6 @@ def test_no_raw_secrets_in_logs(
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA" sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO" sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE" sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
qr_file = tmp_path / "qr_code.json" qr_file = tmp_path / "qr_code.json"
qr_file.write_text( qr_file.write_text(
@@ -1202,8 +1190,7 @@ def test_no_raw_secrets_in_logs(
mocker.patch( mocker.patch(
"pronotepy.ParentClient.token_login", "pronotepy.ParentClient.token_login",
side_effect=pronotepy.PronoteAPIError( side_effect=pronotepy.PronoteAPIError(
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} " f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
f"puis {sentinel_account_pin}"
), ),
) )
@@ -1216,7 +1203,6 @@ def test_no_raw_secrets_in_logs(
auth_mode="qr_token", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin), qr_pin=SecretStr(sentinel_pin),
account_pin=SecretStr(sentinel_account_pin),
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
@@ -1228,12 +1214,10 @@ def test_no_raw_secrets_in_logs(
assert sentinel_token not in message assert sentinel_token not in message
assert sentinel_pin not in message assert sentinel_pin not in message
assert sentinel_jeton not in message assert sentinel_jeton not in message
assert sentinel_account_pin not in message
assert caplog.text assert caplog.text
assert sentinel_token not in caplog.text assert sentinel_token not in caplog.text
assert sentinel_pin not in caplog.text assert sentinel_pin not in caplog.text
assert sentinel_jeton not in caplog.text assert sentinel_jeton not in caplog.text
assert sentinel_account_pin not in caplog.text
# --- Persistence of credentials after data operations --- # --- Persistence of credentials after data operations ---