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.
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/.gitignore +3 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/PKG-INFO +5 -4
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/README.md +4 -3
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/README.md +7 -2
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/_backend.py +1 -1
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/cli.py +35 -22
- flexitracker-0.3.0/src/flexitracker/login.py +106 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/sender.py +18 -2
- flexitracker-0.3.0/tests/test_cli.py +82 -0
- flexitracker-0.3.0/tests/test_login.py +80 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/flexitracker.service +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/install.ps1 +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/install/install.sh +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/pyinstaller_entry.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/pyproject.toml +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/__init__.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/__main__.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/config.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/core.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/idle.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/outbox.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/state_machine.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/src/flexitracker/version.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_config.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_conformance.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_idle.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_outbox.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/test_sender_body.py +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/01-activation-debounced-backdated.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/02-idle-backdated-to-last-input.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/03-short-break-absorbed.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/04-brief-activity-debounced.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/05-sustained-return-backdated.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/06-heartbeats-while-active.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/07-recover-after-sudden-shutdown.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/08-locked-session-goes-idle.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/09-recover-anchors-later-evidence.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/10-suspend-longer-closes-span.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/11-suspend-with-immediate-wake.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/12-suspend-shorter-absorbed.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/13-backwards-clock-watermark.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/14-small-forward-clock-absorbed.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/15-recover-from-active-matches-loop.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/16-explicit-close-no-double-close.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/17-freshness-independent-of-poll.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/18-short-reading-pause.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/19-presence-must-be-sustained.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/20-gap-kills-pending-return.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/21-idle-backdating-exact.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/22-min-inactivity-exact-boundary.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/23-large-timestamps.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/24-all-kinds-lowercase.json +0 -0
- {flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flexitracker
|
|
3
|
-
Version: 0.
|
|
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
|
|
35
|
-
flexitracker
|
|
36
|
-
flexitracker
|
|
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
|
|
27
|
-
flexitracker
|
|
28
|
-
flexitracker
|
|
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
|
|
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
|
|
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
|
|
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: `
|
|
5
|
-
|
|
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,
|
|
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
|
|
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 |
|
|
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 == "
|
|
82
|
-
a.cmd = "
|
|
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 == "
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/01-activation-debounced-backdated.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/02-idle-backdated-to-last-input.json
RENAMED
|
File without changes
|
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/04-brief-activity-debounced.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/05-sustained-return-backdated.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/06-heartbeats-while-active.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/07-recover-after-sudden-shutdown.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/08-locked-session-goes-idle.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/09-recover-anchors-later-evidence.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/10-suspend-longer-closes-span.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/11-suspend-with-immediate-wake.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/12-suspend-shorter-absorbed.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/13-backwards-clock-watermark.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/14-small-forward-clock-absorbed.json
RENAMED
|
File without changes
|
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/16-explicit-close-no-double-close.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/17-freshness-independent-of-poll.json
RENAMED
|
File without changes
|
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/19-presence-must-be-sustained.json
RENAMED
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/20-gap-kills-pending-return.json
RENAMED
|
File without changes
|
|
File without changes
|
{flexitracker-0.2.1.dev0 → flexitracker-0.3.0}/tests/vectors/22-min-inactivity-exact-boundary.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|