fix(offline): logout reopens the Qobuz gate; auth endpoints exempt from it

Logging out of an offline session left offline_session set and the gate
closed, refusing the very OAuth exchange that could end the offline state
(user-reproduced: sign-in failed with OfflineMode after logout). teardown()
now ends the session-scoped offline state (offline_session and the cached
induced flag reset; the persisted preference reloads on the next
activation) and recomputes, reopening the gate when connectivity allows.
Two regression tests added.

The three login methods (login, login_with_oauth_code, login_with_token)
are exempt from the offline gate: user-initiated sign-in is explicit
intent to reach Qobuz; the gate governs services. Verified none of them
reach gated sub-calls; bundle cold-start stays gated on purpose. New unit
test pins the exemption. The recovery sign-in falls back to the full
browser OAuth flow when the saved token is gone or rejected, and
offline_session is lifted only on success paths.

cc
This commit is contained in:
vicrodh committed 2026-06-10 01:32:14 -06:00
1 parent 5fd41e8026
commit c207f232c5
3 files changed
+141 -7

No files matched your search

+62 -2
View File
@@ -118,12 +118,21 @@ impl OfflineModeEngine {
Ok(())
}
/// Drop the per-user store (logout). The induced flag cache is kept until
/// the next `init_for_user` — the gate state must survive the transition.
/// Drop the per-user store AND end the session-scoped offline state
/// (logout). Ending the session ends the session: `offline_session` is
/// reset (it must not survive into the next login attempt — a surviving
/// flag kept the Qobuz gate closed and refused the login itself), and the
/// cached `induced` flag is reset too (no user ⇒ no induced opt-in
/// active; the user's persisted preference reloads from disk on the next
/// `init_for_user`). The final `recompute()` reopens the Qobuz gate when
/// connectivity allows.
pub fn teardown(&self) {
if let Ok(mut guard) = self.store.lock() {
*guard = None;
}
self.offline_session.store(false, Ordering::Relaxed);
self.induced.store(false, Ordering::Relaxed);
self.recompute();
}
/// Subscribe to status changes (UI listeners, QConnect suppressor, ...).
@@ -151,6 +160,13 @@ impl OfflineModeEngine {
store.get_settings()
}
/// Persist the network-folders-in-manual-offline policy flag.
///
/// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network
/// Folder Content" toggle was removed when library visibility stopped
/// depending on offline mode (owner verdict; see qbz-slint's
/// NETWORK-FOLDER VISIBILITY note). Kept (pub, no dead-code warning in a
/// lib crate) because the store column must stay Tauri-DB-compatible.
pub fn set_show_network_folders(&self, enabled: bool) -> Result<(), String> {
let guard = self
.store
@@ -445,6 +461,50 @@ mod tests {
let _ = std::fs::remove_dir_all(audio_dir);
}
#[test]
fn teardown_ends_offline_session_and_reopens_gate() {
let _gate = serialize();
let engine = OfflineModeEngine::new();
engine.on_connectivity(up());
engine.set_offline_session(true);
assert_eq!(engine.status().mode, OfflineMode::RealOffline);
assert!(qbz_qobuz::offline_gate::is_offline());
// Logout: the session-scoped flag must NOT survive — a stale flag
// kept the gate closed and refused the next login.
engine.teardown();
let status = engine.status();
assert_eq!(status.mode, OfflineMode::Online);
assert!(!status.offline_session);
assert!(!qbz_qobuz::offline_gate::is_offline());
}
#[test]
fn teardown_clears_induced_cache_but_disk_restores_it() {
let _gate = serialize();
let dir = unique_test_dir("engine-teardown-induced");
let engine = OfflineModeEngine::new();
engine.init_for_user(&dir).unwrap();
engine.on_connectivity(up());
engine.set_induced(true, None).unwrap();
assert!(qbz_qobuz::offline_gate::is_offline());
// Logout: no user ⇒ no induced opt-in active ⇒ gate open.
engine.teardown();
let status = engine.status();
assert_eq!(status.mode, OfflineMode::Online);
assert!(!status.induced);
assert!(!qbz_qobuz::offline_gate::is_offline());
// The persisted preference survives on disk: the next activation on
// the same dir restores induced offline.
engine.init_for_user(&dir).unwrap();
assert_eq!(engine.status().mode, OfflineMode::InducedOffline);
assert!(qbz_qobuz::offline_gate::is_offline());
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn watch_broadcasts_on_change() {
let _gate = serialize();
+66 -5
View File
@@ -149,9 +149,16 @@ impl QobuzClient {
&self.http
}
/// The single offline choke point (D3): every Qobuz HTTP request flows
/// The single offline choke point (D3): every Qobuz SERVICE request flows
/// through here. While offline mode is active, fail fast with a typed,
/// non-transient error instead of timing out against the network.
///
/// Exemption: the sign-in methods (`login`, `login_with_oauth_code`,
/// `login_with_token`) use the raw `self.http` field instead —
/// user-initiated authentication is an explicit intent to reach Qobuz;
/// the offline gate governs services, not sign-in. Without the exemption
/// a closed gate (induced offline, or a stale offline session) refuses
/// the very login that would resolve it.
fn http(&self) -> Result<&Client> {
if crate::offline_gate::is_offline() {
return Err(ApiError::OfflineMode);
@@ -209,8 +216,10 @@ impl QobuzClient {
/// Login with email and password
pub async fn login(&self, email: &str, password: &str) -> Result<UserSession> {
let url = endpoints::build_url(paths::USER_LOGIN);
// Auth exemption: raw client, bypasses the offline gate (sign-in is
// explicit user intent to reach Qobuz; the gate governs services).
let response = self
.http()?
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("email", email), ("password", password)])
@@ -278,8 +287,10 @@ impl QobuzClient {
);
log::info!("[OAuth] Exchanging code for token via /oauth/callback");
// Auth exemption: raw client, bypasses the offline gate (sign-in is
// explicit user intent to reach Qobuz; the gate governs services).
let callback_response = self
.http()?
.http
.get(&callback_url)
.headers(headers)
.query(&[
@@ -320,8 +331,9 @@ impl QobuzClient {
.map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
);
// Auth exemption: raw client (see /oauth/callback step above).
let login_response = self
.http()?
.http
.post(&user_login_url)
.headers(auth_headers)
.header("Content-Type", "text/plain;charset=UTF-8")
@@ -376,8 +388,10 @@ impl QobuzClient {
);
log::info!("[OAuth] Restoring session from saved token");
// Auth exemption: raw client, bypasses the offline gate (sign-in is
// explicit user intent to reach Qobuz; the gate governs services).
let resp = self
.http()?
.http
.post(&user_login_url)
.headers(headers)
.header("Content-Type", "text/plain;charset=UTF-8")
@@ -2695,4 +2709,51 @@ mod tests {
"offline gate took {elapsed:?} — it must not touch the network"
);
}
/// The sign-in methods are EXEMPT from the offline gate: user-initiated
/// authentication is an explicit intent to reach Qobuz. On an
/// uninitialized client each method gets PAST the closed gate and fails
/// on the missing bundle tokens instead — never `ApiError::OfflineMode`,
/// and without touching the network.
#[tokio::test]
async fn offline_gate_exempts_login_methods() {
let _lock = crate::offline_gate::test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let _reset = crate::offline_gate::TestGateReset;
crate::offline_gate::set_offline(true);
let client = QobuzClient::new().expect("client construction is local-only");
let err = client
.login("user@example.com", "pw")
.await
.err()
.expect("uninitialized client must fail");
assert!(
!matches!(err, ApiError::OfflineMode),
"login must bypass the offline gate, got: {err}"
);
let err = client
.login_with_oauth_code("code")
.await
.err()
.expect("uninitialized client must fail");
assert!(
!matches!(err, ApiError::OfflineMode),
"login_with_oauth_code must bypass the offline gate, got: {err}"
);
let err = client
.login_with_token("token")
.await
.err()
.expect("uninitialized client must fail");
assert!(
!matches!(err, ApiError::OfflineMode),
"login_with_token must bypass the offline gate, got: {err}"
);
}
}
+13
View File
@@ -33,6 +33,19 @@ where
{
let core = runtime.core();
// Belt-and-suspenders: signing in is an explicit intent to reach Qobuz.
// If a session-scoped offline session is still active (e.g. invoked from
// the login screen after a logout that raced the engine reset), end it so
// the still-gated parts of the pipeline (cold bundle init, post-login
// activation) work. The persisted `induced` preference is NOT touched —
// a successful login reloads it via init_for_user and the app correctly
// lands back in induced offline.
let offline_engine = crate::offline_mode::engine();
if offline_engine.status().offline_session {
log::info!("[qbz-slint] sign-in requested with an offline session active — ending it");
offline_engine.set_offline_session(false);
}
// Ensure the Qobuz client is initialized (bundle tokens).
if !core.is_api_initialized().await {
core.try_init_api().await.map_err(|e| e.to_string())?;