obsideo-cli 0.2.8__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/PKG-INFO +1 -1
  2. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/cli.py +145 -16
  3. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/sync.py +27 -8
  4. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/PKG-INFO +1 -1
  5. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/login.py +9 -4
  6. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/pyproject.toml +1 -1
  7. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/tests/test_cli.py +139 -33
  8. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/tests/test_core.py +154 -124
  9. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/README.md +0 -0
  10. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/__init__.py +0 -0
  11. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/__main__.py +0 -0
  12. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/manifest.py +0 -0
  13. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  14. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  15. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
  16. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/requires.txt +0 -0
  17. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/top_level.txt +0 -0
  18. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/__init__.py +0 -0
  19. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/config.py +0 -0
  20. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/crypto.py +0 -0
  21. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/identity.py +0 -0
  22. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/names.py +0 -0
  23. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/storage.py +0 -0
  24. {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -49,6 +49,15 @@ def _human(n: int | None) -> str:
49
49
  f /= 1024
50
50
 
51
51
 
52
+ def _gb(n) -> str:
53
+ """Format a GB count for display: drop a trailing .0 (2.0 -> '2', 2.5 -> '2.5')."""
54
+ try:
55
+ f = float(n)
56
+ except (TypeError, ValueError):
57
+ return str(n)
58
+ return str(int(f)) if f == int(f) else f"{f:g}"
59
+
60
+
52
61
  # ── Operator notices (server-driven broadcasts) ──────────────────────────────
53
62
 
54
63
  _SEEN_FILE = config.CONFIG_DIR / "seen_notices"
@@ -146,10 +155,12 @@ def show_status() -> None:
146
155
  Makes a network call, so it's shown at session start / post-login only."""
147
156
  if not _chrome_enabled() or not config.is_logged_in():
148
157
  return
149
- usage = _fetch_usage()
158
+ usage = _fetch_account_info() or _fetch_usage()
150
159
  if not usage:
151
160
  return
152
161
  used, quota = usage.get("used_bytes", 0), usage.get("quota_bytes", 0)
162
+ if not quota:
163
+ return # no quota to show a bar against; account command shows raw usage
153
164
  pct = usage.get("percent_used")
154
165
  if pct is None:
155
166
  pct = (used / quota) if quota else 0.0
@@ -295,9 +306,11 @@ def run_login(url: str | None = None) -> bool:
295
306
  print(" sent.")
296
307
  print(f"Check {email} for a verification code (it may be in spam).")
297
308
  code = input("Enter verification code: ").strip()
309
+ # Optional friend's referral code -> +1 GB (4 GB instead of 3). Blank = skip.
310
+ referral_code = input("Referral code from a friend (optional, Enter to skip): ").strip()
298
311
  print("Verifying + provisioning storage...", end="", flush=True)
299
312
  try:
300
- creds = login.verify(email, code, url)
313
+ creds = login.verify(email, code, url, referral_code=referral_code or None)
301
314
  except login.LoginError as e:
302
315
  print(f"\nVerification failed: {e}")
303
316
  return False
@@ -306,6 +319,11 @@ def run_login(url: str | None = None) -> bool:
306
319
  # Make sure the data key exists + nudge the user to back it up.
307
320
  crypto.data_key()
308
321
  print(f"\nYou're all set. {creds.get('quota_gb', 3)} GB free.")
322
+ if referral_code:
323
+ if creds.get("referral_applied"):
324
+ print(f"Referral applied - enjoy the extra space! (code {referral_code.upper()})")
325
+ else:
326
+ print(f"That referral code ({referral_code}) wasn't recognized, so no bonus was added.")
309
327
  if not creds.get("gateway_registered", True):
310
328
  print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
311
329
  print("Your files are encrypted with a local key. Back it up:")
@@ -329,6 +347,7 @@ class ObsideoShell(cmd.Cmd):
329
347
  " ls / cd / mkdir browse your files\n"
330
348
  " sync push / pull / status mirror your sync folder\n"
331
349
  " account your plan and usage\n"
350
+ " refer invite friends for free space\n"
332
351
  " about / faq / messages learn more / team news\n\n"
333
352
  " Type 'help <command>' for details (e.g. 'help sync'), or 'exit' to quit.\n"
334
353
  )
@@ -568,27 +587,47 @@ class ObsideoShell(cmd.Cmd):
568
587
  if not self._require_login():
569
588
  return
570
589
  from obsideo import sync as sync_mod
590
+ # Usage + quota for any account via the gateway (works with just the S3
591
+ # creds); fall back to the signup-service token, then to a storage-only
592
+ # count - so this always shows something useful and never nags to log in.
593
+ info = _fetch_account_info() or (_fetch_usage() if config.account_token() else None)
571
594
  print()
572
595
  print(" -- Obsideo account --------------------------")
573
- print(" Plan: Free")
574
- # Prefer the signup service (it knows your quota); otherwise compute usage
575
- # straight from your storage so this always works - never nag to "log in".
576
- usage = _fetch_usage() if config.account_token() else None
577
- if usage:
578
- used, quota = usage["used_bytes"], usage["quota_bytes"]
579
- pct = usage.get("percent_used", (used / quota if quota else 0))
580
- print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
581
- bar_len = 30
582
- filled = int(bar_len * min(pct, 1.0))
583
- print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
584
- if pct >= 0.8:
585
- print(" You're near your limit - reply to any Obsideo email to upgrade.")
596
+ if info:
597
+ tier = (info.get("tier") or "free").replace("testdrive", "Free").title()
598
+ print(f" Plan: {tier}")
599
+ used = info.get("used_bytes", 0)
600
+ quota = info.get("quota_bytes", 0)
601
+ if quota:
602
+ pct = used / quota
603
+ bar_len = 30
604
+ filled = int(bar_len * min(pct, 1.0))
605
+ print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
606
+ print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
607
+ if pct >= 0.8:
608
+ print(" Near your limit - reply to any Obsideo email to upgrade.")
609
+ else:
610
+ print(f" Used: {_human(used)}")
611
+ if info.get("object_count"):
612
+ print(f" Files: {info['object_count']} object(s)")
613
+ if info.get("days_remaining"):
614
+ print(f" Renews/expires in {info['days_remaining']} day(s)")
586
615
  else:
616
+ print(" Plan: Free")
587
617
  try:
588
618
  used, n = storage.total_usage()
589
619
  print(f" Used: {_human(used)} across {n} file(s)")
590
620
  except Exception:
591
621
  print(" Used: (couldn't read storage just now)")
622
+ # Referral summary (only for email-login accounts; pre-shim accounts have
623
+ # no signup token and just don't show this line).
624
+ ref = _fetch_referral() if config.account_token() else None
625
+ if ref:
626
+ if ref.get("active"):
627
+ print(f" Referrals: code {ref['code']} - {ref['active']} active, "
628
+ f"+{_gb(ref.get('earned_gb', 0))} GB earned ('refer' for more)")
629
+ else:
630
+ print(f" Referrals: code {ref['code']} - invite friends for free space ('refer')")
592
631
  print(f" Bucket: {storage.bucket()}")
593
632
  print(f" Sync folder: {sync_mod.ensure_sync_dir()}")
594
633
  print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
@@ -639,7 +678,8 @@ class ObsideoShell(cmd.Cmd):
639
678
  A: `config` shows them; `config set sync_dir <path>` / `config set encrypt_names false`.
640
679
 
641
680
  Q: More space?
642
- A: Reply to any Obsideo email to upgrade. (A referral program is coming.)
681
+ A: Invite friends with `refer` - they get +1 GB, you get +2 GB once they
682
+ upload. Or reply to any Obsideo email to upgrade.
643
683
 
644
684
  Q: Updating?
645
685
  A: pip install -U obsideo-cli - the CLI also nudges you when an update is out.
@@ -665,6 +705,50 @@ class ObsideoShell(cmd.Cmd):
665
705
  print(f" - {n.get('body', '').strip()}")
666
706
  print()
667
707
 
708
+ # ── refer ─────────────────────────────────────────────────────────────────
709
+ def do_refer(self, arg):
710
+ """Invite friends for free space. Usage: refer
711
+
712
+ Share your code: each friend who joins with it gets +1 GB, and you get
713
+ +2 GB once they actually upload something. No limit."""
714
+ if not self._require_login():
715
+ return
716
+ if not config.account_token():
717
+ print(
718
+ "\n Referrals need an email login. Run 'login' to sign up / sign in"
719
+ "\n with your email, then 'refer' shows your invite code.\n"
720
+ )
721
+ return
722
+ info = _fetch_referral()
723
+ if not info:
724
+ print("\n Couldn't load your referral info just now - try again shortly.\n")
725
+ return
726
+
727
+ each = info.get("owner_bonus_gb_each", 2)
728
+ redeemer = info.get("redeemer_bonus_gb", 1)
729
+ print()
730
+ print(" -- Invite friends, get free space --------------------------")
731
+ print(f" Your code: {info['code']}")
732
+ print(f" Share it: a friend runs `obsideo login` and enters your code.")
733
+ print(f" They get +{_gb(redeemer)} GB; you get +{_gb(each)} GB once they upload.")
734
+ print()
735
+ invited = info.get("invited", 0)
736
+ active = info.get("active", 0)
737
+ pending = info.get("pending", 0)
738
+ earned = info.get("earned_gb", 0)
739
+ if invited:
740
+ print(f" Invited: {invited} Active: {active} Pending upload: {pending}")
741
+ print(f" Earned so far: +{_gb(earned)} GB (your quota: {_gb(info.get('quota_gb', 0))} GB)")
742
+ else:
743
+ print(" No referrals yet - share your code to start earning.")
744
+ if info.get("newly_credited"):
745
+ n = info["newly_credited"]
746
+ print(f" ✨ {n} friend(s) just activated - +{_gb(n*each)} GB added!")
747
+ print(" ------------------------------------------------------------")
748
+ print(" Bonuses are a promotional perk; Obsideo reserves the right to change")
749
+ print(" or end the program and to revoke bonuses for abuse.")
750
+ print()
751
+
668
752
  # ── sync ──────────────────────────────────────────────────────────────────
669
753
  def do_sync(self, arg):
670
754
  """Sync your local folder with Obsideo. Usage: sync push|pull|status"""
@@ -742,6 +826,51 @@ def _fetch_usage() -> dict | None:
742
826
  return None
743
827
 
744
828
 
829
+ def _fetch_referral() -> dict | None:
830
+ """Referral code + stats from the signup service (Bearer account token). Also
831
+ triggers the server-side lazy credit check for any friend who just became
832
+ active. Returns None for accounts without a signup token (e.g. pre-shim
833
+ accounts) or if the service is unreachable — callers handle that gracefully."""
834
+ token = config.account_token()
835
+ if not token:
836
+ return None
837
+ try:
838
+ req = urllib.request.Request(
839
+ f"{config.signup_url()}/v1/account/referral",
840
+ headers={"Authorization": f"Bearer {token}", "User-Agent": config.USER_AGENT},
841
+ )
842
+ with urllib.request.urlopen(req, timeout=20, context=config.ssl_context()) as resp:
843
+ return json.loads(resp.read().decode())
844
+ except Exception:
845
+ return None
846
+
847
+
848
+ def _fetch_account_info() -> dict | None:
849
+ """Usage + quota for the calling account via the gateway's SigV4-authed
850
+ /v1/account. Works for ANY account using only the S3 creds we already have
851
+ (no signup-service token needed). Returns {used_bytes, quota_bytes, tier,
852
+ object_count, days_remaining, ...} or None if unavailable (e.g. the gateway
853
+ endpoint isn't deployed yet, or no creds) — callers fall back gracefully."""
854
+ ak = os.environ.get("OBSIDEO_S3_ACCESS_KEY")
855
+ sk = os.environ.get("OBSIDEO_S3_SECRET_KEY")
856
+ if not (ak and sk):
857
+ return None
858
+ try:
859
+ from botocore.auth import SigV4Auth
860
+ from botocore.awsrequest import AWSRequest
861
+ from botocore.credentials import Credentials
862
+ endpoint = os.environ.get("OBSIDEO_S3_ENDPOINT", "https://s3.obsideo.io").rstrip("/")
863
+ region = os.environ.get("OBSIDEO_S3_REGION", "us-east-1")
864
+ url = f"{endpoint}/v1/account"
865
+ signed = AWSRequest(method="GET", url=url)
866
+ SigV4Auth(Credentials(ak, sk), "s3", region).add_auth(signed)
867
+ req = urllib.request.Request(url, headers=dict(signed.headers), method="GET")
868
+ with urllib.request.urlopen(req, timeout=15, context=config.ssl_context()) as resp:
869
+ return json.loads(resp.read().decode())
870
+ except Exception:
871
+ return None
872
+
873
+
745
874
  def main():
746
875
  argv = sys.argv[1:]
747
876
 
@@ -55,28 +55,41 @@ def _remote_key(name: str) -> str:
55
55
  return f"{REMOTE_PREFIX}{name}"
56
56
 
57
57
 
58
+ def _remote_names() -> tuple[set, bool]:
59
+ """Names actually present under the sync prefix on the remote, and whether we
60
+ could reach it. Used to reconcile against the local manifest — the manifest
61
+ alone can't be trusted (e.g. after switching accounts it still 'remembers'
62
+ files uploaded to the OLD account, which aren't on the new one)."""
63
+ try:
64
+ remote = storage.list_prefix(REMOTE_PREFIX)
65
+ return {f["name"] for f in remote["files"]}, True
66
+ except Exception:
67
+ return set(), False
68
+
69
+
58
70
  def sync_status() -> dict:
59
71
  sync_dir = ensure_sync_dir()
60
72
  entries = manifest.get_all()
61
73
  status = {"to_push": [], "to_pull": [], "synced": []}
62
74
 
63
75
  local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file() and f.name != README_NAME}
76
+ remote_names, remote_known = _remote_names()
64
77
 
65
78
  for name, f in local_files.items():
66
79
  local_hash = manifest.file_sha256(f)
67
80
  entry = entries.get(name)
68
- if entry is None or entry.get("local_hash") != local_hash:
81
+ # A file is only "synced" if the manifest matches AND it's really on the
82
+ # remote. If we couldn't reach the remote, fall back to the manifest so a
83
+ # transient outage doesn't flag everything as needing a re-push.
84
+ on_remote = (name in remote_names) if remote_known else True
85
+ if entry is None or entry.get("local_hash") != local_hash or not on_remote:
69
86
  status["to_push"].append(name)
70
87
  else:
71
88
  status["synced"].append(name)
72
89
 
73
90
  # Remote files we know about but don't have locally.
74
- try:
75
- remote = storage.list_prefix(REMOTE_PREFIX)
76
- remote_names = {f["name"] for f in remote["files"]}
77
- except Exception:
78
- remote_names = set(entries.keys())
79
- for name in remote_names:
91
+ pullable = remote_names if remote_known else set(entries.keys())
92
+ for name in pullable:
80
93
  if name not in local_files:
81
94
  status["to_pull"].append(name)
82
95
 
@@ -94,12 +107,18 @@ def push(verbose: bool = True) -> int:
94
107
 
95
108
  do_encrypt = config.load_config().get("encrypt", True)
96
109
  entries = manifest.get_all()
110
+ # Reconcile against the real remote: only skip a file if the manifest matches
111
+ # AND it's actually up there. This self-heals a stale manifest (e.g. after an
112
+ # account switch, where the manifest still lists files from the old account)
113
+ # without the user having to clear anything.
114
+ remote_names, remote_known = _remote_names()
97
115
  pushed = 0
98
116
 
99
117
  for f in files:
100
118
  local_hash = manifest.file_sha256(f)
101
119
  entry = entries.get(f.name)
102
- if entry and entry.get("local_hash") == local_hash:
120
+ on_remote = (f.name in remote_names) if remote_known else True
121
+ if entry and entry.get("local_hash") == local_hash and on_remote:
103
122
  if verbose:
104
123
  print(f" {f.name} - unchanged, skipping")
105
124
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -42,17 +42,22 @@ def start(email: str, url: str | None = None) -> None:
42
42
  _post_json(f"{url}/v1/auth/start", {"email": email})
43
43
 
44
44
 
45
- def verify(email: str, code: str, url: str | None = None) -> dict:
45
+ def verify(email: str, code: str, url: str | None = None,
46
+ referral_code: str | None = None) -> dict:
46
47
  """Verify the code + provision an account. Generates the local signing key,
47
- sends only its public half, persists the returned credentials. Returns the
48
+ sends only its public half, persists the returned credentials. An optional
49
+ referral_code (a friend's invite) grants the new account +1 GB. Returns the
48
50
  credential bundle.
49
51
  """
50
52
  url = url or config.signup_url()
51
53
  signing_pubkey = identity.get_or_create_signing_pubkey()
52
- creds = _post_json(f"{url}/v1/auth/verify", {
54
+ payload = {
53
55
  "email": email,
54
56
  "code": code,
55
57
  "customer_signing_public_key": signing_pubkey,
56
- })
58
+ }
59
+ if referral_code:
60
+ payload["referral_code"] = referral_code
61
+ creds = _post_json(f"{url}/v1/auth/verify", payload)
57
62
  config.write_credentials(creds)
58
63
  return creds
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.2.8"
7
+ version = "0.3.0"
8
8
  description = "Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -282,36 +282,142 @@ def test_messages_handles_unreachable(monkeypatch, capsys):
282
282
  monkeypatch.setattr(cli.urllib.request, "urlopen", boom)
283
283
  cli.ObsideoShell().do_messages("")
284
284
  assert "couldn't reach" in capsys.readouterr().out.lower()
285
-
286
-
287
- def test_sync_readme_created_and_not_pushed(tmp_path, monkeypatch, capsys):
288
- from obsideo import sync
289
- sd = tmp_path / "obsideo-sync"
290
- monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
291
- sync.ensure_sync_dir()
292
- readme = sd / sync.README_NAME
293
- assert readme.exists() and "sync push" in readme.read_text() # guide dropped in
294
- # A folder containing ONLY the README is treated as empty (README never uploads).
295
- n = sync.push(verbose=True)
296
- assert n == 0
297
- assert "empty" in capsys.readouterr().out.lower()
298
-
299
-
300
- def test_account_computes_usage_without_token(monkeypatch, tmp_path, capsys):
301
- # No signup token -> account must compute usage from storage, NOT nag to log in.
302
- from obsideo import sync
303
- monkeypatch.setattr(cli.config, "account_token", lambda: None)
304
- monkeypatch.setattr(cli.storage, "total_usage", lambda: (1_500_000, 7))
305
- monkeypatch.setattr(cli.storage, "bucket", lambda: "tb")
306
- monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
307
- cli.ObsideoShell().do_account("")
308
- out = capsys.readouterr().out
309
- assert "across 7 file" in out
310
- assert "obsideo login" not in out and "sign in" not in out.lower()
311
-
312
-
313
- def test_precmd_strips_obsideo_prefix():
314
- sh = cli.ObsideoShell()
315
- assert sh.precmd("obsideo ls") == "ls"
316
- assert sh.precmd("obsideo login") == "login"
317
- assert sh.precmd("ls") == "ls" # unchanged when no prefix
285
+
286
+
287
+ def test_sync_push_reuploads_when_remote_missing(tmp_path, monkeypatch, capsys):
288
+ # Stale manifest (e.g. after an account switch) says a file is synced, but the
289
+ # remote is actually empty -> push must RE-UPLOAD, not skip. Regression for the
290
+ # "unchanged, skipping" + empty-remote bug after re-login to a new account.
291
+ from obsideo import sync, manifest
292
+ sd = tmp_path / "obsideo-sync"
293
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
294
+ sync.ensure_sync_dir()
295
+ f = sd / "a.txt"; f.write_bytes(b"hello")
296
+ h = manifest.file_sha256(f)
297
+ monkeypatch.setattr(manifest, "get_all", lambda: {"a.txt": {"local_hash": h}})
298
+ monkeypatch.setattr(manifest, "upsert", lambda *a, **k: None)
299
+ monkeypatch.setattr(cli.storage, "list_prefix", lambda prefix: {"folders": [], "files": []})
300
+ put = []
301
+ monkeypatch.setattr(cli.storage, "put", lambda key, body: put.append(key) or key)
302
+ n = sync.push(verbose=True)
303
+ assert n == 1 and put and "a.txt" in put[0]
304
+ assert "uploaded" in capsys.readouterr().out
305
+
306
+
307
+ def test_sync_push_skips_when_present_and_unchanged(tmp_path, monkeypatch, capsys):
308
+ # The optimization still holds: a file that's genuinely unchanged AND present
309
+ # on the remote is skipped (no needless re-upload).
310
+ from obsideo import sync, manifest
311
+ sd = tmp_path / "obsideo-sync"
312
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
313
+ sync.ensure_sync_dir()
314
+ f = sd / "a.txt"; f.write_bytes(b"hello")
315
+ h = manifest.file_sha256(f)
316
+ monkeypatch.setattr(manifest, "get_all", lambda: {"a.txt": {"local_hash": h}})
317
+ monkeypatch.setattr(cli.storage, "list_prefix",
318
+ lambda prefix: {"folders": [], "files": [{"name": "a.txt", "key": "sync/a.txt", "size": 5}]})
319
+ put = []
320
+ monkeypatch.setattr(cli.storage, "put", lambda key, body: put.append(key) or key)
321
+ n = sync.push(verbose=True)
322
+ assert n == 0 and put == []
323
+ assert "skipping" in capsys.readouterr().out
324
+
325
+
326
+ def test_sync_readme_created_and_not_pushed(tmp_path, monkeypatch, capsys):
327
+ from obsideo import sync
328
+ sd = tmp_path / "obsideo-sync"
329
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
330
+ sync.ensure_sync_dir()
331
+ readme = sd / sync.README_NAME
332
+ assert readme.exists() and "sync push" in readme.read_text() # guide dropped in
333
+ # A folder containing ONLY the README is treated as empty (README never uploads).
334
+ n = sync.push(verbose=True)
335
+ assert n == 0
336
+ assert "empty" in capsys.readouterr().out.lower()
337
+
338
+
339
+ def test_account_computes_usage_without_token(monkeypatch, tmp_path, capsys):
340
+ # No gateway info + no signup token -> compute from storage, NOT nag to log in.
341
+ from obsideo import sync
342
+ monkeypatch.setattr(cli, "_fetch_account_info", lambda: None) # don't hit network
343
+ monkeypatch.setattr(cli.config, "account_token", lambda: None)
344
+ monkeypatch.setattr(cli.storage, "total_usage", lambda: (1_500_000, 7))
345
+ monkeypatch.setattr(cli.storage, "bucket", lambda: "tb")
346
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
347
+ cli.ObsideoShell().do_account("")
348
+ out = capsys.readouterr().out
349
+ assert "across 7 file" in out
350
+ assert "obsideo login" not in out and "sign in" not in out.lower()
351
+
352
+
353
+ def test_account_shows_percentage_from_gateway(monkeypatch, tmp_path, capsys):
354
+ # When the gateway returns quota, account shows used / quota / % + a bar.
355
+ from obsideo import sync
356
+ monkeypatch.setattr(cli, "_fetch_account_info",
357
+ lambda: {"tier": "testdrive", "used_bytes": 500_000_000,
358
+ "quota_bytes": 5_368_709_120, "object_count": 314})
359
+ monkeypatch.setattr(cli.storage, "bucket", lambda: "obsideo")
360
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
361
+ cli.ObsideoShell().do_account("")
362
+ out = capsys.readouterr().out
363
+ assert "/" in out and "%" in out and "[" in out # used / quota (pct) + bar
364
+ assert "314 object" in out and "Free" in out # testdrive shown as Free
365
+
366
+
367
+ def test_precmd_strips_obsideo_prefix():
368
+ sh = cli.ObsideoShell()
369
+ assert sh.precmd("obsideo ls") == "ls"
370
+ assert sh.precmd("obsideo login") == "login"
371
+ assert sh.precmd("ls") == "ls" # unchanged when no prefix
372
+
373
+
374
+ # ── referral program ──────────────────────────────────────────────────────────
375
+
376
+ def test_gb_formats_cleanly():
377
+ assert cli._gb(2.0) == "2" and cli._gb(2) == "2"
378
+ assert cli._gb(2.5) == "2.5"
379
+ assert cli._gb(0) == "0"
380
+
381
+
382
+ def test_refer_shows_code_and_stats(monkeypatch, capsys):
383
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
384
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: {
385
+ "code": "ABCD234", "invited": 2, "active": 1, "pending": 1,
386
+ "earned_gb": 2.0, "newly_credited": 1, "owner_bonus_gb_each": 2,
387
+ "redeemer_bonus_gb": 1, "quota_gb": 5.0,
388
+ })
389
+ cli.ObsideoShell().do_refer("")
390
+ out = capsys.readouterr().out
391
+ assert "ABCD234" in out
392
+ assert "Invited: 2" in out and "Active: 1" in out
393
+ assert "+2 GB earned" not in out # that phrasing is the account line, not refer
394
+ assert "just activated" in out # newly_credited celebration
395
+ assert "reserves the right" in out # disclaimer present
396
+
397
+
398
+ def test_refer_without_token_guides_to_login(monkeypatch, capsys):
399
+ monkeypatch.setattr(cli.config, "account_token", lambda: None)
400
+ cli.ObsideoShell().do_refer("")
401
+ out = capsys.readouterr().out.lower()
402
+ assert "login" in out and "email" in out
403
+
404
+
405
+ def test_refer_handles_unreachable_service(monkeypatch, capsys):
406
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
407
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: None)
408
+ cli.ObsideoShell().do_refer("")
409
+ assert "couldn't load" in capsys.readouterr().out.lower()
410
+
411
+
412
+ def test_account_shows_referral_line(monkeypatch, tmp_path, capsys):
413
+ from obsideo import sync
414
+ monkeypatch.setattr(cli, "_fetch_account_info",
415
+ lambda: {"tier": "promo", "used_bytes": 1, "quota_bytes": 5_368_709_120})
416
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
417
+ monkeypatch.setattr(cli, "_fetch_referral",
418
+ lambda: {"code": "ABCD234", "active": 2, "earned_gb": 4.0})
419
+ monkeypatch.setattr(cli.storage, "bucket", lambda: "obsideo")
420
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
421
+ cli.ObsideoShell().do_account("")
422
+ out = capsys.readouterr().out
423
+ assert "Referrals: code ABCD234" in out and "4 GB earned" in out
@@ -1,124 +1,154 @@
1
- """Core tests: crypto round-trip + S3 list parsing (mocked client)."""
2
-
3
- import os
4
- import tempfile
5
- from pathlib import Path
6
- from unittest import mock
7
-
8
- import pytest
9
-
10
- # Isolate config dir so tests never touch ~/.obsideo.
11
- os.environ["OBSIDEO_DATA_KEY"] = os.urandom(32).hex()
12
-
13
- from obsideo_core import crypto, storage
14
-
15
-
16
- def test_crypto_round_trip():
17
- data = os.urandom(4096)
18
- blob = crypto.encrypt(data)
19
- assert blob[:12] != data[:12] # nonce prepended, not plaintext
20
- assert crypto.decrypt(blob) == data
21
-
22
-
23
- def test_crypto_wrong_key_fails():
24
- blob = crypto.encrypt(b"secret")
25
- other = os.urandom(32).hex()
26
- with mock.patch.dict(os.environ, {"OBSIDEO_DATA_KEY": other}):
27
- with pytest.raises(Exception):
28
- crypto.decrypt(blob)
29
-
30
-
31
- @pytest.fixture()
32
- def s3_creds(monkeypatch):
33
- monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKIATEST")
34
- monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
35
- monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
36
- storage.reset_client()
37
- yield
38
- storage.reset_client()
39
-
40
-
41
- def test_list_prefix_parses_folders_and_files(s3_creds, monkeypatch):
42
- monkeypatch.setattr(storage, "_names_on", lambda: False) # plain keys for this test
43
- fake = mock.MagicMock()
44
- fake.list_objects_v2.return_value = {
45
- "CommonPrefixes": [{"Prefix": "docs/"}, {"Prefix": "photos/"}],
46
- "Contents": [
47
- {"Key": "readme.txt", "Size": 12},
48
- {"Key": "note.md", "Size": 40},
49
- {"Key": "", "Size": 0}, # folder marker for root - skipped
50
- ],
51
- "IsTruncated": False,
52
- }
53
- monkeypatch.setattr(storage, "_s3", lambda: fake)
54
-
55
- out = storage.list_prefix("")
56
- assert out["folders"] == ["docs", "photos"]
57
- names = [f["name"] for f in out["files"]]
58
- assert names == ["note.md", "readme.txt"] # sorted
59
-
60
-
61
- def test_get_uses_full_object_get(s3_creds, monkeypatch):
62
- monkeypatch.setattr(storage, "_names_on", lambda: False) # check exact plain key
63
- fake = mock.MagicMock()
64
- body = mock.MagicMock(); body.read.return_value = b"ciphertext"
65
- fake.get_object.return_value = {"Body": body}
66
- monkeypatch.setattr(storage, "_s3", lambda: fake)
67
-
68
- assert storage.get("k") == b"ciphertext"
69
- fake.get_object.assert_called_once_with(Bucket="obsideo", Key="k")
70
- assert not fake.download_file.called # never ranged
71
-
72
-
73
- # ── filename encryption (Level 1 metadata privacy) ──────────────────────────
74
-
75
- def test_name_encryption_round_trip():
76
- from obsideo_core import names
77
- tok = names.encrypt_name("my secret folder")
78
- assert tok != "my secret folder"
79
- assert "/" not in tok # safe as a single path component
80
- assert names.decrypt_name(tok) == "my secret folder"
81
-
82
-
83
- def test_name_encryption_is_deterministic():
84
- from obsideo_core import names
85
- # Deterministic so server-side prefix listing works.
86
- assert names.encrypt_name("photos") == names.encrypt_name("photos")
87
-
88
-
89
- def test_encrypt_path_per_component():
90
- from obsideo_core import names
91
- enc = names.encrypt_path("a/b/c.txt")
92
- assert enc.count("/") == 2 # structure preserved (3 components)
93
- assert "a" not in enc and "c.txt" not in enc # names hidden
94
-
95
-
96
- def test_storage_stores_under_encrypted_key(s3_creds, monkeypatch):
97
- fake = mock.MagicMock()
98
- monkeypatch.setattr(storage, "_s3", lambda: fake)
99
- monkeypatch.setattr(storage, "ensure_bucket", lambda: None)
100
- storage.put("docs/secret.txt", b"x") # names ON by default
101
- _, args, kwargs = fake.mock_calls[0]
102
- stored_key = args[2]
103
- assert "secret.txt" not in stored_key and "docs" not in stored_key
104
-
105
-
106
- def test_list_prefix_decrypts_names_roundtrip(s3_creds, monkeypatch):
107
- """Store under encrypted keys, then list and confirm real names come back."""
108
- from obsideo_core import names
109
- enc_prefix = names.encrypt_path("vault") + "/"
110
- enc_file = enc_prefix + names.encrypt_name("passwords.txt")
111
- enc_sub = enc_prefix + names.encrypt_name("sub") + "/"
112
-
113
- fake = mock.MagicMock()
114
- fake.list_objects_v2.return_value = {
115
- "CommonPrefixes": [{"Prefix": enc_sub}],
116
- "Contents": [{"Key": enc_file, "Size": 99}],
117
- "IsTruncated": False,
118
- }
119
- monkeypatch.setattr(storage, "_s3", lambda: fake)
120
-
121
- out = storage.list_prefix("vault")
122
- assert out["folders"] == ["sub"]
123
- assert out["files"][0]["name"] == "passwords.txt"
124
- assert out["files"][0]["key"] == "vault/passwords.txt" # real path for re-fetch
1
+ """Core tests: crypto round-trip + S3 list parsing (mocked client)."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ import pytest
9
+
10
+ # Isolate config dir so tests never touch ~/.obsideo.
11
+ os.environ["OBSIDEO_DATA_KEY"] = os.urandom(32).hex()
12
+
13
+ from obsideo_core import crypto, storage
14
+
15
+
16
+ def test_crypto_round_trip():
17
+ data = os.urandom(4096)
18
+ blob = crypto.encrypt(data)
19
+ assert blob[:12] != data[:12] # nonce prepended, not plaintext
20
+ assert crypto.decrypt(blob) == data
21
+
22
+
23
+ def test_crypto_wrong_key_fails():
24
+ blob = crypto.encrypt(b"secret")
25
+ other = os.urandom(32).hex()
26
+ with mock.patch.dict(os.environ, {"OBSIDEO_DATA_KEY": other}):
27
+ with pytest.raises(Exception):
28
+ crypto.decrypt(blob)
29
+
30
+
31
+ @pytest.fixture()
32
+ def s3_creds(monkeypatch):
33
+ monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKIATEST")
34
+ monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
35
+ monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
36
+ storage.reset_client()
37
+ yield
38
+ storage.reset_client()
39
+
40
+
41
+ def test_list_prefix_parses_folders_and_files(s3_creds, monkeypatch):
42
+ monkeypatch.setattr(storage, "_names_on", lambda: False) # plain keys for this test
43
+ fake = mock.MagicMock()
44
+ fake.list_objects_v2.return_value = {
45
+ "CommonPrefixes": [{"Prefix": "docs/"}, {"Prefix": "photos/"}],
46
+ "Contents": [
47
+ {"Key": "readme.txt", "Size": 12},
48
+ {"Key": "note.md", "Size": 40},
49
+ {"Key": "", "Size": 0}, # folder marker for root - skipped
50
+ ],
51
+ "IsTruncated": False,
52
+ }
53
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
54
+
55
+ out = storage.list_prefix("")
56
+ assert out["folders"] == ["docs", "photos"]
57
+ names = [f["name"] for f in out["files"]]
58
+ assert names == ["note.md", "readme.txt"] # sorted
59
+
60
+
61
+ def test_get_uses_full_object_get(s3_creds, monkeypatch):
62
+ monkeypatch.setattr(storage, "_names_on", lambda: False) # check exact plain key
63
+ fake = mock.MagicMock()
64
+ body = mock.MagicMock(); body.read.return_value = b"ciphertext"
65
+ fake.get_object.return_value = {"Body": body}
66
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
67
+
68
+ assert storage.get("k") == b"ciphertext"
69
+ fake.get_object.assert_called_once_with(Bucket="obsideo", Key="k")
70
+ assert not fake.download_file.called # never ranged
71
+
72
+
73
+ # ── filename encryption (Level 1 metadata privacy) ──────────────────────────
74
+
75
+ def test_name_encryption_round_trip():
76
+ from obsideo_core import names
77
+ tok = names.encrypt_name("my secret folder")
78
+ assert tok != "my secret folder"
79
+ assert "/" not in tok # safe as a single path component
80
+ assert names.decrypt_name(tok) == "my secret folder"
81
+
82
+
83
+ def test_name_encryption_is_deterministic():
84
+ from obsideo_core import names
85
+ # Deterministic so server-side prefix listing works.
86
+ assert names.encrypt_name("photos") == names.encrypt_name("photos")
87
+
88
+
89
+ def test_encrypt_path_per_component():
90
+ from obsideo_core import names
91
+ enc = names.encrypt_path("a/b/c.txt")
92
+ segs = enc.split("/")
93
+ assert len(segs) == 3 # structure preserved (3 components)
94
+ # Each component is hidden (token != plaintext) yet round-trips back. Compare
95
+ # per-segment rather than substring-scanning the whole path: a 1-char name
96
+ # like "a" appears by chance inside base64 tokens, which made this flaky.
97
+ for plain, tok in zip(["a", "b", "c.txt"], segs):
98
+ assert tok != plain
99
+ assert names.decrypt_name(tok) == plain
100
+
101
+
102
+ def test_storage_stores_under_encrypted_key(s3_creds, monkeypatch):
103
+ fake = mock.MagicMock()
104
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
105
+ monkeypatch.setattr(storage, "ensure_bucket", lambda: None)
106
+ storage.put("docs/secret.txt", b"x") # names ON by default
107
+ _, args, kwargs = fake.mock_calls[0]
108
+ stored_key = args[2]
109
+ assert "secret.txt" not in stored_key and "docs" not in stored_key
110
+
111
+
112
+ def test_list_prefix_decrypts_names_roundtrip(s3_creds, monkeypatch):
113
+ """Store under encrypted keys, then list and confirm real names come back."""
114
+ from obsideo_core import names
115
+ enc_prefix = names.encrypt_path("vault") + "/"
116
+ enc_file = enc_prefix + names.encrypt_name("passwords.txt")
117
+ enc_sub = enc_prefix + names.encrypt_name("sub") + "/"
118
+
119
+ fake = mock.MagicMock()
120
+ fake.list_objects_v2.return_value = {
121
+ "CommonPrefixes": [{"Prefix": enc_sub}],
122
+ "Contents": [{"Key": enc_file, "Size": 99}],
123
+ "IsTruncated": False,
124
+ }
125
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
126
+
127
+ out = storage.list_prefix("vault")
128
+ assert out["folders"] == ["sub"]
129
+ assert out["files"][0]["name"] == "passwords.txt"
130
+ assert out["files"][0]["key"] == "vault/passwords.txt" # real path for re-fetch
131
+
132
+
133
+ # ── login: referral code threading ────────────────────────────────────────────
134
+
135
+ def test_verify_includes_referral_code_when_given(monkeypatch):
136
+ from obsideo_core import login, identity, config
137
+ sent = {}
138
+ monkeypatch.setattr(login, "_post_json", lambda url, payload: sent.update(payload) or {"access_key": "AKIA", "secret_key": "s"})
139
+ monkeypatch.setattr(identity, "get_or_create_signing_pubkey", lambda: "obk_sig_" + "A" * 43)
140
+ monkeypatch.setattr(config, "write_credentials", lambda creds: None)
141
+
142
+ login.verify("a@b.com", "123456", url="http://x", referral_code="ABCD234")
143
+ assert sent["referral_code"] == "ABCD234"
144
+
145
+
146
+ def test_verify_omits_referral_code_when_blank(monkeypatch):
147
+ from obsideo_core import login, identity, config
148
+ sent = {}
149
+ monkeypatch.setattr(login, "_post_json", lambda url, payload: sent.update(payload) or {"access_key": "AKIA", "secret_key": "s"})
150
+ monkeypatch.setattr(identity, "get_or_create_signing_pubkey", lambda: "obk_sig_" + "A" * 43)
151
+ monkeypatch.setattr(config, "write_credentials", lambda creds: None)
152
+
153
+ login.verify("a@b.com", "123456", url="http://x")
154
+ assert "referral_code" not in sent # never send an empty/None code
File without changes
File without changes