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:
3 files changed
+141
-7
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())?;
|
||||
|
||||
Reference in new issue
Block a user