flexitracker 0.2.1.dev0__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 (53) hide show
  1. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/.gitignore +3 -0
  2. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/PKG-INFO +5 -4
  3. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/README.md +4 -3
  4. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/README.md +7 -2
  5. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/_backend.py +1 -1
  6. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/cli.py +35 -22
  7. flexitracker-0.3.0/src/flexitracker/login.py +106 -0
  8. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/sender.py +18 -2
  9. flexitracker-0.3.0/tests/test_cli.py +82 -0
  10. flexitracker-0.3.0/tests/test_login.py +80 -0
  11. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/flexitracker.service +0 -0
  12. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/install.ps1 +0 -0
  13. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/install.sh +0 -0
  14. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/pyinstaller_entry.py +0 -0
  15. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/pyproject.toml +0 -0
  16. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/__init__.py +0 -0
  17. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/__main__.py +0 -0
  18. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/config.py +0 -0
  19. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/core.py +0 -0
  20. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/idle.py +0 -0
  21. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/outbox.py +0 -0
  22. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/state_machine.py +0 -0
  23. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/version.py +0 -0
  24. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_config.py +0 -0
  25. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_conformance.py +0 -0
  26. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_idle.py +0 -0
  27. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_outbox.py +0 -0
  28. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_sender_body.py +0 -0
  29. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/01-activation-debounced-backdated.json +0 -0
  30. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/02-idle-backdated-to-last-input.json +0 -0
  31. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/03-short-break-absorbed.json +0 -0
  32. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/04-brief-activity-debounced.json +0 -0
  33. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/05-sustained-return-backdated.json +0 -0
  34. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/06-heartbeats-while-active.json +0 -0
  35. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/07-recover-after-sudden-shutdown.json +0 -0
  36. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/08-locked-session-goes-idle.json +0 -0
  37. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/09-recover-anchors-later-evidence.json +0 -0
  38. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/10-suspend-longer-closes-span.json +0 -0
  39. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/11-suspend-with-immediate-wake.json +0 -0
  40. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/12-suspend-shorter-absorbed.json +0 -0
  41. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/13-backwards-clock-watermark.json +0 -0
  42. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/14-small-forward-clock-absorbed.json +0 -0
  43. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/15-recover-from-active-matches-loop.json +0 -0
  44. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/16-explicit-close-no-double-close.json +0 -0
  45. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/17-freshness-independent-of-poll.json +0 -0
  46. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/18-short-reading-pause.json +0 -0
  47. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/19-presence-must-be-sustained.json +0 -0
  48. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/20-gap-kills-pending-return.json +0 -0
  49. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/21-idle-backdating-exact.json +0 -0
  50. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/22-min-inactivity-exact-boundary.json +0 -0
  51. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/23-large-timestamps.json +0 -0
  52. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/24-all-kinds-lowercase.json +0 -0
  53. {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/uv.lock +0 -0
@@ -18,3 +18,6 @@ daemon-py/build/
18
18
  # Screenshots / MCP artifacts
19
19
  .playwright-mcp/
20
20
  /*.png
21
+
22
+ # Personal activity export (migrated into PROD, not for the repo)
23
+ /events.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexitracker
3
- Version: 0.2.1.dev0
3
+ Version: 0.3.0
4
4
  Summary: FlexiTracker activity daemon — pure-Python.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -31,9 +31,10 @@ Windows SmartScreen trust step.
31
31
  ## Use
32
32
 
33
33
  ```bash
34
- flexitracker configure --key <ACCESS_KEY> # authorize this machine, then self-test
35
- flexitracker test # connectivity check, sends no data
36
- flexitracker # run the daemon
34
+ flexitracker login # authorize this machine in a browser, then self-test
35
+ flexitracker login --key <ACCESS_KEY> # headless/scripted: authorize with a pasted key instead
36
+ flexitracker test # connectivity check, sends no data
37
+ flexitracker # run the daemon
37
38
  ```
38
39
 
39
40
  ## Develop
@@ -23,9 +23,10 @@ Windows SmartScreen trust step.
23
23
  ## Use
24
24
 
25
25
  ```bash
26
- flexitracker configure --key <ACCESS_KEY> # authorize this machine, then self-test
27
- flexitracker test # connectivity check, sends no data
28
- flexitracker # run the daemon
26
+ flexitracker login # authorize this machine in a browser, then self-test
27
+ flexitracker login --key <ACCESS_KEY> # headless/scripted: authorize with a pasted key instead
28
+ flexitracker test # connectivity check, sends no data
29
+ flexitracker # run the daemon
29
30
  ```
30
31
 
31
32
  ## Develop
@@ -8,10 +8,15 @@ it works on managed machines that block unsigned executables.
8
8
 
9
9
  ```bash
10
10
  uv tool install flexitracker
11
- flexitracker configure --key <YOUR_ACCESS_KEY>
11
+ flexitracker login
12
12
  flexitracker test
13
13
  ```
14
14
 
15
+ `login` opens your browser to authorize the machine — sign in, approve, and the
16
+ key lands in the config automatically; you never see or paste it. On a
17
+ headless or scripted box, use `flexitracker login --key <YOUR_ACCESS_KEY>`
18
+ with a key from the web UI's "Add machine" button instead.
19
+
15
20
  Then enable auto-start on login:
16
21
 
17
22
  - **Linux:** `./install.sh` (installs a systemd *user* service).
@@ -25,7 +30,7 @@ If your machine permits running executables, download the standalone
25
30
  **Releases** page — it bundles its own Python runtime, so nothing else is needed.
26
31
 
27
32
  ```bat
28
- flexitracker.exe configure --key <YOUR_ACCESS_KEY>
33
+ flexitracker.exe login
29
34
  flexitracker.exe test
30
35
  ```
31
36
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mirrors the Rust `option_env!("FLEXITRACKER_BACKEND_URL")`: the release pipeline
4
4
  rewrites `BAKED_BACKEND_URL` (from the PROD_BASE_URL variable) so a user runs only
5
- `flexitracker configure --key <KEY>`. The `FLEXITRACKER_BACKEND_URL` environment
5
+ `flexitracker login`. The `FLEXITRACKER_BACKEND_URL` environment
6
6
  variable overrides it, and `--backend-url` overrides both.
7
7
  """
8
8
 
@@ -1,8 +1,9 @@
1
1
  """CLI + daemon loop.
2
2
 
3
3
  Fail-fast: unexpected conditions exit with a clear message rather than being
4
- silently absorbed. Subcommands: `configure` (authorize + self-test), `test`
5
- (connectivity check, sends no data), and the default daemon run.
4
+ silently absorbed. Subcommands: `login` (authorize + self-test, browser by
5
+ default or `--key` for headless/scripted setups), `test` (connectivity check,
6
+ sends no data), and the default daemon run.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
@@ -18,7 +19,8 @@ from typing import Optional
18
19
  from ._backend import default_backend_url
19
20
  from .config import Config
20
21
  from .core import machine_descriptor
21
- from .idle import Sample, SimulatedIdle, platform_source
22
+ from .idle import Sample, platform_source
23
+ from .login import LoginError, browser_login
22
24
  from .outbox import Outbox
23
25
  from .sender import SenderError, fetch_thresholds, post_batch, whoami
24
26
  from .state_machine import Persisted, StateMachine, Tick
@@ -26,12 +28,14 @@ from .state_machine import Persisted, StateMachine, Tick
26
28
  HELP = """flexitracker — activity tracking daemon
27
29
 
28
30
  USAGE:
29
- flexitracker configure [--key KEY] [--backend-url URL] Authorize this machine
31
+ flexitracker login [--name LABEL] [--backend-url URL] Authorize this machine (opens a browser)
32
+ flexitracker login --key KEY [--backend-url URL] Authorize with a pasted key (no browser)
30
33
  flexitracker test Check connectivity (sends no data)
31
34
  flexitracker [OPTIONS] Run the daemon
32
35
 
33
36
  OPTIONS:
34
- --key, --account-key KEY Per-machine access key (saved to config)
37
+ --key, --account-key KEY Per-machine access key for `login --key` (saved to config)
38
+ --name LABEL Machine label for the browser `login` flow (default: hostname)
35
39
  --backend-url URL Backend base URL (defaults to the built-in one)
36
40
  --config PATH Config file path (default: ~/.config/flexitracker/config.toml)
37
41
  --simulate Post a synthetic day through the real pipeline and exit
@@ -59,8 +63,9 @@ def machine_desc() -> dict:
59
63
 
60
64
  class Args:
61
65
  def __init__(self) -> None:
62
- self.cmd = "daemon" # daemon | configure | test
66
+ self.cmd = "daemon" # daemon | login | test
63
67
  self.account_key: Optional[str] = None
68
+ self.name: Optional[str] = None
64
69
  self.backend_url: Optional[str] = None
65
70
  self.config_path: Optional[Path] = None
66
71
  self.simulate = False
@@ -78,12 +83,14 @@ def parse_args(argv: list) -> Args:
78
83
  raise ValueError(f"{flag} requires a value")
79
84
 
80
85
  for arg in it:
81
- if arg == "configure":
82
- a.cmd = "configure"
86
+ if arg == "login":
87
+ a.cmd = "login"
83
88
  elif arg in ("test", "--check"):
84
89
  a.cmd = "test"
85
90
  elif arg in ("--account-key", "--key"):
86
91
  a.account_key = take(arg)
92
+ elif arg == "--name":
93
+ a.name = take(arg)
87
94
  elif arg == "--backend-url":
88
95
  a.backend_url = take(arg)
89
96
  elif arg == "--config":
@@ -105,12 +112,6 @@ def parse_args(argv: list) -> Args:
105
112
  return a
106
113
 
107
114
 
108
- def prompt(msg: str) -> str:
109
- sys.stdout.write(msg)
110
- sys.stdout.flush()
111
- return sys.stdin.readline().strip()
112
-
113
-
114
115
  def self_test(cfg: Config) -> int:
115
116
  print(f"Contacting {cfg.backend_url} …")
116
117
  try:
@@ -215,28 +216,40 @@ def run(argv: list) -> int:
215
216
  if not cfg.backend_url:
216
217
  cfg.backend_url = default_backend_url()
217
218
 
218
- if args.cmd == "configure":
219
- if not cfg.access_key:
220
- cfg.access_key = prompt("Paste your machine access key: ")
221
- if not cfg.access_key:
222
- print("error: no access key provided", file=sys.stderr)
223
- return 1
219
+ if args.cmd == "login":
224
220
  if not cfg.backend_url:
225
221
  print("error: no backend url (pass --backend-url; releases have one built in)", file=sys.stderr)
226
222
  return 1
223
+ if args.account_key:
224
+ # Manual/headless form: cfg.access_key is already set above from
225
+ # args.account_key. No browser involved.
226
+ cfg.save(config_path)
227
+ print(f"Saved config to {config_path}.")
228
+ return self_test(cfg)
229
+ # Default: browser-based device authorization. No key is ever entered
230
+ # or displayed — a failure here leaves config untouched (nothing is
231
+ # saved until browser_login returns successfully).
232
+ label = args.name or machine_desc()["hostname"]
233
+ try:
234
+ access_key, machine_id = browser_login(cfg.backend_url, label)
235
+ except LoginError as e:
236
+ print(f"error: login failed: {e}", file=sys.stderr)
237
+ return 1
238
+ cfg.access_key = access_key
239
+ cfg.machine_id = machine_id
227
240
  cfg.save(config_path)
228
241
  print(f"Saved config to {config_path}.")
229
242
  return self_test(cfg)
230
243
 
231
244
  if args.cmd == "test":
232
245
  if not cfg.access_key or not cfg.backend_url:
233
- print("error: not configured — run `flexitracker configure --key <KEY>` first", file=sys.stderr)
246
+ print("error: not configured — run `flexitracker login` first", file=sys.stderr)
234
247
  return 1
235
248
  return self_test(cfg)
236
249
 
237
250
  # Daemon
238
251
  if not cfg.access_key or not cfg.backend_url:
239
- print("error: missing access key or backend url (run `flexitracker configure --key <KEY>`)", file=sys.stderr)
252
+ print("error: missing access key or backend url (run `flexitracker login`)", file=sys.stderr)
240
253
  return 1
241
254
  cfg.save(config_path)
242
255
 
@@ -0,0 +1,106 @@
1
+ """Browser-based device-authorization login (the `login` command's default flow).
2
+
3
+ Loopback pattern (`gh auth login --web` / `wrangler login`): bind an ephemeral
4
+ listener on 127.0.0.1, open the backend's /device/authorize in the system
5
+ browser, and receive a one-time authorization code back over the loopback
6
+ redirect. The code is then exchanged for the access key at /device/token — the
7
+ key itself never appears in a URL, not even a loopback one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import http.server
13
+ import secrets
14
+ import threading
15
+ import webbrowser
16
+ from typing import Callable, Optional
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ from . import sender
20
+
21
+ LOGIN_TIMEOUT_SEC = 180
22
+
23
+
24
+ class LoginError(Exception):
25
+ pass
26
+
27
+
28
+ class _Result:
29
+ def __init__(self) -> None:
30
+ self.code: Optional[str] = None
31
+ self.error: Optional[str] = None
32
+
33
+
34
+ def _handler_factory(result: _Result, expected_state: str, done: threading.Event):
35
+ class Handler(http.server.BaseHTTPRequestHandler):
36
+ def do_GET(self) -> None: # noqa: N802 (stdlib-mandated method name)
37
+ qs = parse_qs(urlparse(self.path).query)
38
+ state = (qs.get("state") or [None])[0]
39
+ code = (qs.get("code") or [None])[0]
40
+ if state != expected_state:
41
+ result.error = "callback state mismatch"
42
+ elif not code:
43
+ result.error = "no authorization code in callback"
44
+ else:
45
+ result.code = code
46
+ self.send_response(200)
47
+ self.send_header("Content-Type", "text/html; charset=utf-8")
48
+ self.end_headers()
49
+ msg = (
50
+ "Authorization failed — you can close this tab."
51
+ if result.error
52
+ else "FlexiTracker authorized — you can close this tab."
53
+ )
54
+ self.wfile.write(msg.encode("utf-8"))
55
+ done.set()
56
+
57
+ def log_message(self, *args) -> None: # silence default stderr request logging
58
+ pass
59
+
60
+ return Handler
61
+
62
+
63
+ def browser_login(
64
+ backend_url: str,
65
+ label: str,
66
+ *,
67
+ open_browser: Callable[[str], bool] = webbrowser.open,
68
+ timeout_sec: int = LOGIN_TIMEOUT_SEC,
69
+ ) -> tuple[str, str]:
70
+ """Run the loopback authorization handshake. Returns (access_key, machine_id).
71
+
72
+ Raises LoginError on timeout, a state mismatch, a missing/failed callback,
73
+ or a bad token exchange — in every failure case, no config has been
74
+ written (the caller only writes config on a successful return).
75
+ """
76
+ state = secrets.token_urlsafe(24)
77
+ result = _Result()
78
+ done = threading.Event()
79
+ server = http.server.HTTPServer(("127.0.0.1", 0), _handler_factory(result, state, done))
80
+ port = server.server_address[1]
81
+ thread = threading.Thread(target=server.handle_request, daemon=True)
82
+ thread.start()
83
+
84
+ url = (
85
+ f"{backend_url.rstrip('/')}/device/authorize?"
86
+ + urlencode({"label": label, "cb": f"127.0.0.1:{port}", "state": state})
87
+ )
88
+ print(f"Opening browser to authorize: {url}")
89
+ if not open_browser(url):
90
+ print("Could not open a browser automatically — open this URL manually:")
91
+ print(url)
92
+
93
+ finished = done.wait(timeout_sec)
94
+ server.server_close()
95
+ if not finished:
96
+ raise LoginError(f"timed out after {timeout_sec}s waiting for browser approval")
97
+ if result.error:
98
+ raise LoginError(result.error)
99
+ assert result.code is not None # error is always set above when code is falsy
100
+
101
+ data = sender.device_token(backend_url, result.code)
102
+ access_key = data.get("access_key")
103
+ machine_id = data.get("machine_id")
104
+ if not access_key or not machine_id:
105
+ raise LoginError("malformed token response from backend")
106
+ return access_key, machine_id
@@ -20,9 +20,11 @@ class SenderError(Exception):
20
20
  pass
21
21
 
22
22
 
23
- def _request(url: str, key: str, method: str, body: Optional[dict]) -> dict:
23
+ def _request(url: str, key: Optional[str], method: str, body: Optional[dict]) -> dict:
24
24
  data = None
25
- headers = {"authorization": f"Bearer {key}"}
25
+ headers = {}
26
+ if key:
27
+ headers["authorization"] = f"Bearer {key}"
26
28
  if body is not None:
27
29
  data = json.dumps(body).encode("utf-8")
28
30
  headers["content-type"] = "application/json"
@@ -81,6 +83,20 @@ def whoami(base: str, key: str) -> WhoAmI:
81
83
  )
82
84
 
83
85
 
86
+ def device_token(base: str, code: str) -> dict:
87
+ """Exchange a one-time device-authorization code for the minted access key
88
+ (the `login` browser flow's final step). No access key is sent — the code
89
+ itself is the authorization; the endpoint is Access-bypassed like /ingest.
90
+ """
91
+ url = f"{base.rstrip('/')}/device/token"
92
+ try:
93
+ return _request(url, None, "POST", {"code": code})
94
+ except urllib.error.HTTPError as e:
95
+ raise SenderError(f"server returned {e.code}") from e
96
+ except urllib.error.URLError as e:
97
+ raise SenderError(str(e.reason)) from e
98
+
99
+
84
100
  def fetch_thresholds(base: str, key: str, poll_sec: int) -> ThresholdCfg:
85
101
  """Fetch current thresholds from the backend (keeps the caller's poll)."""
86
102
  url = f"{base.rstrip('/')}/config"
@@ -0,0 +1,82 @@
1
+ import pytest
2
+
3
+ from flexitracker import cli as cli_mod
4
+ from flexitracker.cli import parse_args, run
5
+ from flexitracker.config import Config
6
+ from flexitracker.login import LoginError
7
+
8
+
9
+ def test_configure_is_no_longer_a_recognized_command():
10
+ with pytest.raises(ValueError, match="unknown argument: configure"):
11
+ parse_args(["configure"])
12
+
13
+
14
+ def test_login_with_key_writes_config_without_a_browser(tmp_path, monkeypatch):
15
+ called = []
16
+ monkeypatch.setattr(cli_mod, "browser_login", lambda *a, **k: called.append(1) or ("AK", "MID"))
17
+ monkeypatch.setattr(cli_mod, "self_test", lambda cfg: 0)
18
+ config_path = tmp_path / "config.toml"
19
+ rc = run(
20
+ [
21
+ "login",
22
+ "--key",
23
+ "PASTED-KEY",
24
+ "--backend-url",
25
+ "https://backend.example",
26
+ "--config",
27
+ str(config_path),
28
+ ]
29
+ )
30
+ assert rc == 0
31
+ assert called == [] # the browser flow was never invoked
32
+ saved = Config.load(config_path)
33
+ assert saved.access_key == "PASTED-KEY"
34
+ assert saved.backend_url == "https://backend.example"
35
+
36
+
37
+ def test_login_browser_flow_writes_config_and_runs_self_test(tmp_path, monkeypatch):
38
+ monkeypatch.setattr(cli_mod, "browser_login", lambda backend_url, label: ("BROWSER-KEY", "BROWSER-MID"))
39
+ ran_self_test = []
40
+ monkeypatch.setattr(cli_mod, "self_test", lambda cfg: ran_self_test.append(cfg.access_key) or 0)
41
+ config_path = tmp_path / "config.toml"
42
+ rc = run(["login", "--backend-url", "https://backend.example", "--config", str(config_path)])
43
+ assert rc == 0
44
+ assert ran_self_test == ["BROWSER-KEY"]
45
+ saved = Config.load(config_path)
46
+ assert saved.access_key == "BROWSER-KEY"
47
+ assert saved.machine_id == "BROWSER-MID"
48
+
49
+
50
+ def test_login_name_flag_is_passed_as_the_label(tmp_path, monkeypatch):
51
+ seen = []
52
+
53
+ def fake_browser_login(backend_url, label):
54
+ seen.append(label)
55
+ return "K", "M"
56
+
57
+ monkeypatch.setattr(cli_mod, "browser_login", fake_browser_login)
58
+ monkeypatch.setattr(cli_mod, "self_test", lambda cfg: 0)
59
+ config_path = tmp_path / "config.toml"
60
+ run(
61
+ [
62
+ "login",
63
+ "--name",
64
+ "Work laptop",
65
+ "--backend-url",
66
+ "https://backend.example",
67
+ "--config",
68
+ str(config_path),
69
+ ]
70
+ )
71
+ assert seen == ["Work laptop"]
72
+
73
+
74
+ def test_login_browser_failure_writes_no_config(tmp_path, monkeypatch):
75
+ def boom(*a, **k):
76
+ raise LoginError("timed out after 180s waiting for browser approval")
77
+
78
+ monkeypatch.setattr(cli_mod, "browser_login", boom)
79
+ config_path = tmp_path / "config.toml"
80
+ rc = run(["login", "--backend-url", "https://backend.example", "--config", str(config_path)])
81
+ assert rc == 1
82
+ assert not config_path.exists()
@@ -0,0 +1,80 @@
1
+ import threading
2
+ import urllib.request
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+ import pytest
6
+
7
+ from flexitracker import login as login_mod
8
+ from flexitracker.login import LoginError, browser_login
9
+
10
+
11
+ def _fake_open_browser(*, state_override=None, hit=True):
12
+ """An open_browser stand-in that (optionally) hits the loopback callback
13
+ URL embedded in the authorize URL it's given, from a background thread —
14
+ mimicking the browser completing (or not completing) the approval."""
15
+
16
+ def opener(url: str) -> bool:
17
+ if not hit:
18
+ return True # pretend the browser opened, but the user never approves
19
+ qs = parse_qs(urlparse(url).query)
20
+ cb = qs["cb"][0]
21
+ state = state_override if state_override is not None else qs["state"][0]
22
+ callback_url = f"http://{cb}/?code=the-code&state={state}"
23
+
24
+ def hit_callback() -> None:
25
+ try:
26
+ urllib.request.urlopen(callback_url, timeout=5) # noqa: S310
27
+ except OSError:
28
+ pass
29
+
30
+ threading.Thread(target=hit_callback, daemon=True).start()
31
+ return True
32
+
33
+ return opener
34
+
35
+
36
+ def test_successful_exchange_returns_key_and_machine_id(monkeypatch):
37
+ monkeypatch.setattr(
38
+ login_mod.sender,
39
+ "device_token",
40
+ lambda base, code: {"access_key": "AK", "machine_id": "MID"} if code == "the-code" else {},
41
+ )
42
+ access_key, machine_id = browser_login(
43
+ "https://backend.example", "Work laptop", open_browser=_fake_open_browser(), timeout_sec=5
44
+ )
45
+ assert access_key == "AK"
46
+ assert machine_id == "MID"
47
+
48
+
49
+ def test_state_mismatch_is_rejected(monkeypatch):
50
+ exchanged = []
51
+ monkeypatch.setattr(login_mod.sender, "device_token", lambda *a: exchanged.append(a) or {})
52
+ with pytest.raises(LoginError, match="state mismatch"):
53
+ browser_login(
54
+ "https://backend.example",
55
+ "Work laptop",
56
+ open_browser=_fake_open_browser(state_override="wrong-state"),
57
+ timeout_sec=5,
58
+ )
59
+ assert exchanged == [] # a rejected callback must never reach the token exchange
60
+
61
+
62
+ def test_timeout_raises_and_never_exchanges(monkeypatch):
63
+ exchanged = []
64
+ monkeypatch.setattr(login_mod.sender, "device_token", lambda *a: exchanged.append(a) or {})
65
+ with pytest.raises(LoginError, match="timed out"):
66
+ browser_login(
67
+ "https://backend.example",
68
+ "Work laptop",
69
+ open_browser=_fake_open_browser(hit=False),
70
+ timeout_sec=0.3,
71
+ )
72
+ assert exchanged == []
73
+
74
+
75
+ def test_malformed_token_response_raises(monkeypatch):
76
+ monkeypatch.setattr(login_mod.sender, "device_token", lambda base, code: {})
77
+ with pytest.raises(LoginError, match="malformed"):
78
+ browser_login(
79
+ "https://backend.example", "Work laptop", open_browser=_fake_open_browser(), timeout_sec=5
80
+ )
File without changes