mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Add offline mode feature
This commit is contained in:
@@ -62,6 +62,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Change Master Password:** Rotate your encryption password at any time.
|
- **Change Master Password:** Rotate your encryption password at any time.
|
||||||
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
||||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||||
|
- **Offline Mode:** Disable all Nostr communication for local-only operation.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
@@ -93,6 +93,7 @@ Manage profile‑specific settings.
|
|||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| Get a setting value | `config get` | `seedpass config get kdf_iterations` |
|
| Get a setting value | `config get` | `seedpass config get kdf_iterations` |
|
||||||
| Set a setting value | `config set` | `seedpass config set backup_interval 3600` |
|
| Set a setting value | `config set` | `seedpass config set backup_interval 3600` |
|
||||||
|
| Toggle offline mode | `config toggle-offline` | `seedpass config toggle-offline` |
|
||||||
|
|
||||||
### Fingerprint Commands
|
### Fingerprint Commands
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ Code: 123456
|
|||||||
- **`seedpass config get <key>`** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, or password policy fields like `min_uppercase`.
|
- **`seedpass config get <key>`** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, or password policy fields like `min_uppercase`.
|
||||||
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, or `min_special` to adjust password complexity.
|
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, or `min_special` to adjust password complexity.
|
||||||
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
||||||
|
- **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations.
|
||||||
|
|
||||||
### `fingerprint` Commands
|
### `fingerprint` Commands
|
||||||
|
|
||||||
|
@@ -60,6 +60,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Change Master Password:** Rotate your encryption password at any time.
|
- **Change Master Password:** Rotate your encryption password at any time.
|
||||||
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
||||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||||
|
- **Offline Mode:** Disable network sync to work entirely locally.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
@@ -90,6 +90,7 @@ class NostrClient:
|
|||||||
fingerprint: str,
|
fingerprint: str,
|
||||||
relays: Optional[List[str]] = None,
|
relays: Optional[List[str]] = None,
|
||||||
parent_seed: Optional[str] = None,
|
parent_seed: Optional[str] = None,
|
||||||
|
offline_mode: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.encryption_manager = encryption_manager
|
self.encryption_manager = encryption_manager
|
||||||
self.fingerprint = fingerprint
|
self.fingerprint = fingerprint
|
||||||
@@ -110,7 +111,11 @@ class NostrClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.keys = Keys.generate()
|
self.keys = Keys.generate()
|
||||||
|
|
||||||
self.relays = relays if relays else DEFAULT_RELAYS
|
self.offline_mode = offline_mode
|
||||||
|
if relays is None:
|
||||||
|
self.relays = [] if offline_mode else DEFAULT_RELAYS
|
||||||
|
else:
|
||||||
|
self.relays = relays
|
||||||
|
|
||||||
# store the last error encountered during network operations
|
# store the last error encountered during network operations
|
||||||
self.last_error: Optional[str] = None
|
self.last_error: Optional[str] = None
|
||||||
@@ -127,19 +132,27 @@ class NostrClient:
|
|||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
"""Connect the client to all configured relays."""
|
"""Connect the client to all configured relays."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
self.initialize_client_pool()
|
self.initialize_client_pool()
|
||||||
|
|
||||||
def initialize_client_pool(self) -> None:
|
def initialize_client_pool(self) -> None:
|
||||||
"""Add relays to the client and connect."""
|
"""Add relays to the client and connect."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return
|
||||||
asyncio.run(self._initialize_client_pool())
|
asyncio.run(self._initialize_client_pool())
|
||||||
|
|
||||||
async def _connect_async(self) -> None:
|
async def _connect_async(self) -> None:
|
||||||
"""Ensure the client is connected within an async context."""
|
"""Ensure the client is connected within an async context."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
await self._initialize_client_pool()
|
await self._initialize_client_pool()
|
||||||
|
|
||||||
async def _initialize_client_pool(self) -> None:
|
async def _initialize_client_pool(self) -> None:
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return
|
||||||
if hasattr(self.client, "add_relays"):
|
if hasattr(self.client, "add_relays"):
|
||||||
await self.client.add_relays(self.relays)
|
await self.client.add_relays(self.relays)
|
||||||
else:
|
else:
|
||||||
@@ -181,6 +194,8 @@ class NostrClient:
|
|||||||
|
|
||||||
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
|
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
|
||||||
"""Ping relays and return the count of those providing data."""
|
"""Ping relays and return the count of those providing data."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return 0
|
||||||
return asyncio.run(self._check_relay_health(min_relays, timeout))
|
return asyncio.run(self._check_relay_health(min_relays, timeout))
|
||||||
|
|
||||||
def publish_json_to_nostr(
|
def publish_json_to_nostr(
|
||||||
@@ -201,6 +216,8 @@ class NostrClient:
|
|||||||
If provided, include an ``alt`` tag so uploads can be
|
If provided, include an ``alt`` tag so uploads can be
|
||||||
associated with a specific event like a password change.
|
associated with a specific event like a password change.
|
||||||
"""
|
"""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
self.connect()
|
self.connect()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
try:
|
try:
|
||||||
@@ -233,10 +250,14 @@ class NostrClient:
|
|||||||
|
|
||||||
def publish_event(self, event):
|
def publish_event(self, event):
|
||||||
"""Publish a prepared event to the configured relays."""
|
"""Publish a prepared event to the configured relays."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
self.connect()
|
self.connect()
|
||||||
return asyncio.run(self._publish_event(event))
|
return asyncio.run(self._publish_event(event))
|
||||||
|
|
||||||
async def _publish_event(self, event):
|
async def _publish_event(self, event):
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
return await self.client.send_event(event)
|
return await self.client.send_event(event)
|
||||||
|
|
||||||
@@ -252,6 +273,8 @@ class NostrClient:
|
|||||||
self, retries: int = 0, delay: float = 2.0
|
self, retries: int = 0, delay: float = 2.0
|
||||||
) -> Optional[bytes]:
|
) -> Optional[bytes]:
|
||||||
"""Retrieve the latest Kind 1 event from the author with optional retries."""
|
"""Retrieve the latest Kind 1 event from the author with optional retries."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
self.connect()
|
self.connect()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
attempt = 0
|
attempt = 0
|
||||||
@@ -270,6 +293,8 @@ class NostrClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
# Filter for the latest text note (Kind 1) from our public key
|
# Filter for the latest text note (Kind 1) from our public key
|
||||||
pubkey = self.keys.public_key()
|
pubkey = self.keys.public_key()
|
||||||
@@ -304,6 +329,8 @@ class NostrClient:
|
|||||||
Maximum chunk size in bytes. Defaults to 50 kB.
|
Maximum chunk size in bytes. Defaults to 50 kB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return Manifest(ver=1, algo="gzip", chunks=[]), ""
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
||||||
for meta, chunk in zip(manifest.chunks, chunks):
|
for meta, chunk in zip(manifest.chunks, chunks):
|
||||||
@@ -336,7 +363,8 @@ class NostrClient:
|
|||||||
|
|
||||||
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
||||||
"""Retrieve the latest manifest and all snapshot chunks."""
|
"""Retrieve the latest manifest and all snapshot chunks."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return None
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
|
|
||||||
pubkey = self.keys.public_key()
|
pubkey = self.keys.public_key()
|
||||||
@@ -376,7 +404,8 @@ class NostrClient:
|
|||||||
|
|
||||||
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
||||||
"""Publish a delta event referencing a manifest."""
|
"""Publish a delta event referencing a manifest."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return ""
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
|
|
||||||
content = base64.b64encode(delta_bytes).decode("utf-8")
|
content = base64.b64encode(delta_bytes).decode("utf-8")
|
||||||
@@ -392,7 +421,8 @@ class NostrClient:
|
|||||||
|
|
||||||
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
||||||
"""Retrieve delta events newer than the given version."""
|
"""Retrieve delta events newer than the given version."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return []
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
|
|
||||||
pubkey = self.keys.public_key()
|
pubkey = self.keys.public_key()
|
||||||
|
@@ -41,6 +41,7 @@ class ConfigManager:
|
|||||||
logger.info("Config file not found; returning defaults")
|
logger.info("Config file not found; returning defaults")
|
||||||
return {
|
return {
|
||||||
"relays": list(DEFAULT_NOSTR_RELAYS),
|
"relays": list(DEFAULT_NOSTR_RELAYS),
|
||||||
|
"offline_mode": False,
|
||||||
"pin_hash": "",
|
"pin_hash": "",
|
||||||
"password_hash": "",
|
"password_hash": "",
|
||||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||||
@@ -61,6 +62,7 @@ class ConfigManager:
|
|||||||
raise ValueError("Config data must be a dictionary")
|
raise ValueError("Config data must be a dictionary")
|
||||||
# Ensure defaults for missing keys
|
# Ensure defaults for missing keys
|
||||||
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
|
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
|
||||||
|
data.setdefault("offline_mode", False)
|
||||||
data.setdefault("pin_hash", "")
|
data.setdefault("pin_hash", "")
|
||||||
data.setdefault("password_hash", "")
|
data.setdefault("password_hash", "")
|
||||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||||
@@ -196,11 +198,22 @@ class ConfigManager:
|
|||||||
config["secret_mode_enabled"] = bool(enabled)
|
config["secret_mode_enabled"] = bool(enabled)
|
||||||
self.save_config(config)
|
self.save_config(config)
|
||||||
|
|
||||||
|
def set_offline_mode(self, enabled: bool) -> None:
|
||||||
|
"""Persist the offline mode toggle."""
|
||||||
|
config = self.load_config(require_pin=False)
|
||||||
|
config["offline_mode"] = bool(enabled)
|
||||||
|
self.save_config(config)
|
||||||
|
|
||||||
def get_secret_mode_enabled(self) -> bool:
|
def get_secret_mode_enabled(self) -> bool:
|
||||||
"""Retrieve whether secret mode is enabled."""
|
"""Retrieve whether secret mode is enabled."""
|
||||||
config = self.load_config(require_pin=False)
|
config = self.load_config(require_pin=False)
|
||||||
return bool(config.get("secret_mode_enabled", False))
|
return bool(config.get("secret_mode_enabled", False))
|
||||||
|
|
||||||
|
def get_offline_mode(self) -> bool:
|
||||||
|
"""Retrieve the offline mode setting."""
|
||||||
|
config = self.load_config(require_pin=False)
|
||||||
|
return bool(config.get("offline_mode", False))
|
||||||
|
|
||||||
def set_clipboard_clear_delay(self, delay: int) -> None:
|
def set_clipboard_clear_delay(self, delay: int) -> None:
|
||||||
"""Persist clipboard clear timeout in seconds."""
|
"""Persist clipboard clear timeout in seconds."""
|
||||||
if delay <= 0:
|
if delay <= 0:
|
||||||
|
@@ -135,6 +135,7 @@ class PasswordManager:
|
|||||||
self.inactivity_timeout: float = INACTIVITY_TIMEOUT
|
self.inactivity_timeout: float = INACTIVITY_TIMEOUT
|
||||||
self.secret_mode_enabled: bool = False
|
self.secret_mode_enabled: bool = False
|
||||||
self.clipboard_clear_delay: int = 45
|
self.clipboard_clear_delay: int = 45
|
||||||
|
self.offline_mode: bool = False
|
||||||
self.profile_stack: list[tuple[str, Path, str]] = []
|
self.profile_stack: list[tuple[str, Path, str]] = []
|
||||||
self.last_unlock_duration: float | None = None
|
self.last_unlock_duration: float | None = None
|
||||||
|
|
||||||
@@ -982,17 +983,19 @@ class PasswordManager:
|
|||||||
# Load relay configuration and initialize NostrClient
|
# Load relay configuration and initialize NostrClient
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
relay_list = config.get("relays", list(DEFAULT_RELAYS))
|
relay_list = config.get("relays", list(DEFAULT_RELAYS))
|
||||||
|
self.offline_mode = bool(config.get("offline_mode", False))
|
||||||
self.inactivity_timeout = config.get(
|
self.inactivity_timeout = config.get(
|
||||||
"inactivity_timeout", INACTIVITY_TIMEOUT
|
"inactivity_timeout", INACTIVITY_TIMEOUT
|
||||||
)
|
)
|
||||||
self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False))
|
self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False))
|
||||||
self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45))
|
self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45))
|
||||||
|
if not self.offline_mode:
|
||||||
print("Connecting to relays...")
|
print("Connecting to relays...")
|
||||||
self.nostr_client = NostrClient(
|
self.nostr_client = NostrClient(
|
||||||
encryption_manager=self.encryption_manager,
|
encryption_manager=self.encryption_manager,
|
||||||
fingerprint=self.current_fingerprint,
|
fingerprint=self.current_fingerprint,
|
||||||
relays=relay_list,
|
relays=relay_list,
|
||||||
|
offline_mode=self.offline_mode,
|
||||||
parent_seed=getattr(self, "parent_seed", None),
|
parent_seed=getattr(self, "parent_seed", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1028,6 +1031,8 @@ class PasswordManager:
|
|||||||
|
|
||||||
def start_background_sync(self) -> None:
|
def start_background_sync(self) -> None:
|
||||||
"""Launch a thread to synchronize the vault without blocking the UI."""
|
"""Launch a thread to synchronize the vault without blocking the UI."""
|
||||||
|
if getattr(self, "offline_mode", False):
|
||||||
|
return
|
||||||
if (
|
if (
|
||||||
hasattr(self, "_sync_thread")
|
hasattr(self, "_sync_thread")
|
||||||
and self._sync_thread
|
and self._sync_thread
|
||||||
@@ -3312,6 +3317,8 @@ class PasswordManager:
|
|||||||
def sync_vault(self, alt_summary: str | None = None) -> str | None:
|
def sync_vault(self, alt_summary: str | None = None) -> str | None:
|
||||||
"""Publish the current vault contents to Nostr."""
|
"""Publish the current vault contents to Nostr."""
|
||||||
try:
|
try:
|
||||||
|
if getattr(self, "offline_mode", False):
|
||||||
|
return None
|
||||||
encrypted = self.get_encrypted_data()
|
encrypted = self.get_encrypted_data()
|
||||||
if not encrypted:
|
if not encrypted:
|
||||||
return None
|
return None
|
||||||
|
@@ -535,6 +535,41 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
|||||||
typer.echo(f"Secret mode {status}.")
|
typer.echo(f"Secret mode {status}.")
|
||||||
|
|
||||||
|
|
||||||
|
@config_app.command("toggle-offline")
|
||||||
|
def config_toggle_offline(ctx: typer.Context) -> None:
|
||||||
|
"""Enable or disable offline mode."""
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
cfg = pm.config_manager
|
||||||
|
try:
|
||||||
|
enabled = cfg.get_offline_mode()
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error loading settings: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}")
|
||||||
|
choice = (
|
||||||
|
typer.prompt(
|
||||||
|
"Enable offline mode? (y/n, blank to keep)", default="", show_default=False
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if choice in ("y", "yes"):
|
||||||
|
enabled = True
|
||||||
|
elif choice in ("n", "no"):
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg.set_offline_mode(enabled)
|
||||||
|
pm.offline_mode = enabled
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
status = "enabled" if enabled else "disabled"
|
||||||
|
typer.echo(f"Offline mode {status}.")
|
||||||
|
|
||||||
|
|
||||||
@fingerprint_app.command("list")
|
@fingerprint_app.command("list")
|
||||||
def fingerprint_list(ctx: typer.Context) -> None:
|
def fingerprint_list(ctx: typer.Context) -> None:
|
||||||
"""List available seed profiles."""
|
"""List available seed profiles."""
|
||||||
|
@@ -63,8 +63,10 @@ class DummyPM:
|
|||||||
set_clipboard_clear_delay=lambda v: None,
|
set_clipboard_clear_delay=lambda v: None,
|
||||||
set_additional_backup_path=lambda v: None,
|
set_additional_backup_path=lambda v: None,
|
||||||
set_relays=lambda v, require_pin=False: None,
|
set_relays=lambda v, require_pin=False: None,
|
||||||
|
set_offline_mode=lambda v: None,
|
||||||
get_secret_mode_enabled=lambda: True,
|
get_secret_mode_enabled=lambda: True,
|
||||||
get_clipboard_clear_delay=lambda: 30,
|
get_clipboard_clear_delay=lambda: 30,
|
||||||
|
get_offline_mode=lambda: False,
|
||||||
)
|
)
|
||||||
self.secret_mode_enabled = True
|
self.secret_mode_enabled = True
|
||||||
self.clipboard_clear_delay = 30
|
self.clipboard_clear_delay = 30
|
||||||
|
40
src/tests/test_cli_toggle_offline_mode.py
Normal file
40
src/tests/test_cli_toggle_offline_mode.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from seedpass.cli import app
|
||||||
|
from seedpass import cli
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pm(called, enabled=False):
|
||||||
|
cfg = SimpleNamespace(
|
||||||
|
get_offline_mode=lambda: enabled,
|
||||||
|
set_offline_mode=lambda v: called.setdefault("enabled", v),
|
||||||
|
)
|
||||||
|
pm = SimpleNamespace(
|
||||||
|
config_manager=cfg,
|
||||||
|
offline_mode=enabled,
|
||||||
|
select_fingerprint=lambda fp: None,
|
||||||
|
)
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_offline_updates(monkeypatch):
|
||||||
|
called = {}
|
||||||
|
pm = _make_pm(called)
|
||||||
|
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||||
|
result = runner.invoke(app, ["config", "toggle-offline"], input="y\n")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert called == {"enabled": True}
|
||||||
|
assert "Offline mode enabled." in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_offline_keep(monkeypatch):
|
||||||
|
called = {}
|
||||||
|
pm = _make_pm(called, enabled=True)
|
||||||
|
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||||
|
result = runner.invoke(app, ["config", "toggle-offline"], input="\n")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert called == {"enabled": True}
|
||||||
|
assert "Offline mode enabled." in result.stdout
|
27
src/tests/test_offline_mode_behavior.py
Normal file
27
src/tests/test_offline_mode_behavior.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from password_manager.manager import PasswordManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_vault_skips_network(monkeypatch):
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.offline_mode = True
|
||||||
|
pm.get_encrypted_data = lambda: b"data"
|
||||||
|
called = {"nostr": False}
|
||||||
|
pm.nostr_client = SimpleNamespace(
|
||||||
|
publish_snapshot=lambda *a, **kw: called.__setitem__("nostr", True)
|
||||||
|
)
|
||||||
|
result = PasswordManager.sync_vault(pm)
|
||||||
|
assert result is None
|
||||||
|
assert called["nostr"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_background_sync_offline(monkeypatch):
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.offline_mode = True
|
||||||
|
called = {"sync": False}
|
||||||
|
pm.sync_index_from_nostr = lambda: called.__setitem__("sync", True)
|
||||||
|
PasswordManager.start_background_sync(pm)
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert called["sync"] is False
|
Reference in New Issue
Block a user