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.
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/PKG-INFO +1 -1
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/cli.py +145 -16
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/sync.py +27 -8
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/PKG-INFO +1 -1
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/login.py +9 -4
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/pyproject.toml +1 -1
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/tests/test_cli.py +139 -33
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/tests/test_core.py +154 -124
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/README.md +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/SOURCES.txt +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/names.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/obsideo_core/storage.py +0 -0
- {obsideo_cli-0.2.8 → obsideo_cli-0.3.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
sync
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
93
|
-
assert
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|