current-cli 0.1.59__tar.gz → 0.1.60__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.
- {current_cli-0.1.59 → current_cli-0.1.60}/PKG-INFO +8 -1
- {current_cli-0.1.59 → current_cli-0.1.60}/README.md +7 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/docs.py +5 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/main.py +83 -2
- {current_cli-0.1.59 → current_cli-0.1.60}/llms.txt +7 -1
- {current_cli-0.1.59 → current_cli-0.1.60}/pyproject.toml +1 -1
- current_cli-0.1.60/tests/test_auth.py +213 -0
- current_cli-0.1.59/tests/test_auth.py +0 -92
- {current_cli-0.1.59 → current_cli-0.1.60}/.gitignore +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/__init__.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/build.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/config.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/http.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/schema.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/current_cli/schema.yml +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/scripts/bump_version.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/__init__.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/conftest.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/test_bump_version.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/test_command_tree.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/test_docs.py +0 -0
- {current_cli-0.1.59 → current_cli-0.1.60}/tests/test_requests.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: current-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.60
|
|
4
4
|
Summary: Command line interface for the Current API, generated from its OpenAPI schema at runtime
|
|
5
5
|
Author-email: Orin <your-email@example.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -41,6 +41,13 @@ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
|
|
|
41
41
|
current workspaces members list <workspace_id>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
`current login` emails a magic link: paste the whole link (or just its token)
|
|
45
|
+
when asked. Accounts that require two-factor authentication — Orin Labs
|
|
46
|
+
superadmins, workspace admins, and members of workspaces that require it —
|
|
47
|
+
are then asked for an authenticator code. An account with no authenticator
|
|
48
|
+
enrolled yet is shown a setup link and secret key to add to an authenticator
|
|
49
|
+
app, and confirms the first code to finish signing in.
|
|
50
|
+
|
|
44
51
|
The CLI points at `https://api.current.orinlabs.ai` by default. For local
|
|
45
52
|
development, run `current config set base_url http://localhost:8000`.
|
|
46
53
|
|
|
@@ -28,6 +28,13 @@ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
|
|
|
28
28
|
current workspaces members list <workspace_id>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
`current login` emails a magic link: paste the whole link (or just its token)
|
|
32
|
+
when asked. Accounts that require two-factor authentication — Orin Labs
|
|
33
|
+
superadmins, workspace admins, and members of workspaces that require it —
|
|
34
|
+
are then asked for an authenticator code. An account with no authenticator
|
|
35
|
+
enrolled yet is shown a setup link and secret key to add to an authenticator
|
|
36
|
+
app, and confirms the first code to finish signing in.
|
|
37
|
+
|
|
31
38
|
The CLI points at `https://api.current.orinlabs.ai` by default. For local
|
|
32
39
|
development, run `current config set base_url http://localhost:8000`.
|
|
33
40
|
|
|
@@ -24,6 +24,11 @@ current login you@example.com # magic-link sign in, sto
|
|
|
24
24
|
current config set default_workspace <workspace-uuid> # used when --workspace is omitted
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`current login` asks for the sign-in link from the email (or its token). An
|
|
28
|
+
account that requires two-factor authentication is also asked for an
|
|
29
|
+
authenticator code; one with no device enrolled yet is shown a setup link
|
|
30
|
+
and secret key to add to an authenticator app first.
|
|
31
|
+
|
|
27
32
|
The CLI speaks to `https://api.current.orinlabs.ai` by default. For another
|
|
28
33
|
server (for example local development), set
|
|
29
34
|
`current config set base_url http://localhost:8000`.
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
from urllib.parse import parse_qs, urlsplit
|
|
7
8
|
|
|
8
9
|
import click
|
|
9
10
|
|
|
@@ -16,12 +17,87 @@ MAGIC_LINK_PATH = "/api/accounts/auth/magic-link/"
|
|
|
16
17
|
VERIFY_PATH = "/api/accounts/auth/verify/"
|
|
17
18
|
LOGOUT_PATH = "/api/accounts/auth/logout/"
|
|
18
19
|
ME_PATH = "/api/accounts/auth/me/"
|
|
20
|
+
MFA_VERIFY_PATH = "/api/accounts/auth/mfa/verify/"
|
|
21
|
+
MFA_SETUP_PATH = "/api/accounts/auth/mfa/setup/"
|
|
22
|
+
MFA_CONFIRM_PATH = "/api/accounts/auth/mfa/confirm/"
|
|
23
|
+
|
|
24
|
+
# Tries at the authenticator code before login gives up. The pending-login
|
|
25
|
+
# ticket outlives these by far (10 minutes), so a typo costs a retry, not
|
|
26
|
+
# another magic link.
|
|
27
|
+
MFA_MAX_ATTEMPTS = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _magic_token(value: str) -> str:
|
|
31
|
+
"""The magic-link token from what the user pasted: the whole sign-in link
|
|
32
|
+
from the email (``.../auth/verify?token=...``) or the bare token."""
|
|
33
|
+
value = value.strip()
|
|
34
|
+
if "token=" in value:
|
|
35
|
+
tokens = parse_qs(urlsplit(value).query or value).get("token")
|
|
36
|
+
if tokens:
|
|
37
|
+
return tokens[0]
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detail(response) -> str:
|
|
42
|
+
try:
|
|
43
|
+
body = response.json()
|
|
44
|
+
except ValueError:
|
|
45
|
+
return response.text
|
|
46
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), str):
|
|
47
|
+
return body["detail"]
|
|
48
|
+
return response.text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fail(response) -> None:
|
|
52
|
+
"""Exit on a failed sign-in step. An expired pending-login ticket gets a
|
|
53
|
+
plain instruction instead of the raw API error."""
|
|
54
|
+
if "ticket" in _detail(response).lower():
|
|
55
|
+
click.echo("The sign-in expired. Run `current login` again.", err=True)
|
|
56
|
+
raise SystemExit(1)
|
|
57
|
+
http.render_response(response)
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _second_factor(ticket: str, enrolled: bool) -> dict:
|
|
62
|
+
"""Finish a sign-in that needs two-factor authentication and return the
|
|
63
|
+
login payload. An enrolled account verifies a code; an account without a
|
|
64
|
+
device enrolls one first, and confirming it completes the sign-in."""
|
|
65
|
+
if enrolled:
|
|
66
|
+
click.echo("This account requires two-factor authentication.")
|
|
67
|
+
path = MFA_VERIFY_PATH
|
|
68
|
+
else:
|
|
69
|
+
setup = http.request(
|
|
70
|
+
"post", MFA_SETUP_PATH, json_body={"ticket": ticket}, authenticated=False
|
|
71
|
+
)
|
|
72
|
+
if setup.status_code >= 400:
|
|
73
|
+
_fail(setup)
|
|
74
|
+
enrollment = setup.json()
|
|
75
|
+
click.echo("This account requires two-factor authentication. Add it to an")
|
|
76
|
+
click.echo("authenticator app with the setup link or the secret key:")
|
|
77
|
+
click.echo(f" Setup link: {enrollment['otpauth_url']}")
|
|
78
|
+
click.echo(f" Secret key: {enrollment['secret']}")
|
|
79
|
+
path = MFA_CONFIRM_PATH
|
|
80
|
+
|
|
81
|
+
for attempt in range(1, MFA_MAX_ATTEMPTS + 1):
|
|
82
|
+
code = "".join(click.prompt("Authenticator code").split())
|
|
83
|
+
response = http.request(
|
|
84
|
+
"post", path, json_body={"ticket": ticket, "code": code}, authenticated=False
|
|
85
|
+
)
|
|
86
|
+
if response.status_code < 400:
|
|
87
|
+
return response.json()
|
|
88
|
+
retryable = response.status_code == 400 and "ticket" not in _detail(response).lower()
|
|
89
|
+
if retryable and attempt < MFA_MAX_ATTEMPTS:
|
|
90
|
+
click.echo(f"{_detail(response)} Try again.", err=True)
|
|
91
|
+
continue
|
|
92
|
+
_fail(response)
|
|
93
|
+
raise AssertionError("unreachable")
|
|
19
94
|
|
|
20
95
|
|
|
21
96
|
@click.command()
|
|
22
97
|
@click.argument("email")
|
|
23
98
|
def login(email: str) -> None:
|
|
24
|
-
"""Sign in with a magic link and
|
|
99
|
+
"""Sign in with a magic link (and an authenticator code when the account
|
|
100
|
+
requires two-factor authentication) and store the auth token."""
|
|
25
101
|
response = http.request(
|
|
26
102
|
"post", MAGIC_LINK_PATH, json_body={"email": email}, authenticated=False
|
|
27
103
|
)
|
|
@@ -34,7 +110,7 @@ def login(email: str) -> None:
|
|
|
34
110
|
click.echo("Using debug token from the API (DEBUG mode).")
|
|
35
111
|
else:
|
|
36
112
|
click.echo(f"Magic link sent to {email}.")
|
|
37
|
-
magic_token = click.prompt("Paste the
|
|
113
|
+
magic_token = _magic_token(click.prompt("Paste the sign-in link or its token"))
|
|
38
114
|
|
|
39
115
|
verify = http.request(
|
|
40
116
|
"post", VERIFY_PATH, json_body={"token": magic_token}, authenticated=False
|
|
@@ -44,6 +120,11 @@ def login(email: str) -> None:
|
|
|
44
120
|
return
|
|
45
121
|
|
|
46
122
|
payload = verify.json()
|
|
123
|
+
if payload.get("mfa_required"):
|
|
124
|
+
payload = _second_factor(payload["ticket"], bool(payload.get("mfa_enrolled")))
|
|
125
|
+
|
|
126
|
+
# Saved only once a real token is in hand: a sign-in abandoned part-way
|
|
127
|
+
# leaves the previous config as it was.
|
|
47
128
|
config_store.set_value("token", payload["token"])
|
|
48
129
|
config_store.set_value("email", email)
|
|
49
130
|
click.echo(f"Logged in as {email}.")
|
|
@@ -11,6 +11,11 @@ current login you@example.com # magic-link sign in, sto
|
|
|
11
11
|
current config set default_workspace <workspace-uuid> # used when --workspace is omitted
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
`current login` asks for the sign-in link from the email (or its token). An
|
|
15
|
+
account that requires two-factor authentication is also asked for an
|
|
16
|
+
authenticator code; one with no device enrolled yet is shown a setup link
|
|
17
|
+
and secret key to add to an authenticator app first.
|
|
18
|
+
|
|
14
19
|
The CLI speaks to `https://api.current.orinlabs.ai` by default. For another
|
|
15
20
|
server (for example local development), set
|
|
16
21
|
`current config set base_url http://localhost:8000`.
|
|
@@ -559,7 +564,8 @@ Options:
|
|
|
559
564
|
|
|
560
565
|
### current login <email>
|
|
561
566
|
|
|
562
|
-
Sign in with a magic link and
|
|
567
|
+
Sign in with a magic link (and an authenticator code when the account
|
|
568
|
+
requires two-factor authentication) and store the auth token.
|
|
563
569
|
|
|
564
570
|
### current logout
|
|
565
571
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The login flow must match the API's magic-link contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from current_cli import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_auth_handler(debug_token: str | None = "magic-1"):
|
|
11
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
12
|
+
if request.url.path == "/api/accounts/auth/magic-link/":
|
|
13
|
+
payload = {"detail": "Magic link sent."}
|
|
14
|
+
if debug_token:
|
|
15
|
+
payload["debug_token"] = debug_token
|
|
16
|
+
return httpx.Response(200, json=payload)
|
|
17
|
+
if request.url.path == "/api/accounts/auth/verify/":
|
|
18
|
+
body = json.loads(request.content)
|
|
19
|
+
if body["token"] != "magic-1":
|
|
20
|
+
return httpx.Response(400, json={"detail": "Invalid or expired link."})
|
|
21
|
+
return httpx.Response(
|
|
22
|
+
200, json={"token": "drf-token", "user": {"email": "dev@example.com"}}
|
|
23
|
+
)
|
|
24
|
+
if request.url.path == "/api/accounts/auth/me/":
|
|
25
|
+
return httpx.Response(200, json={"email": "dev@example.com"})
|
|
26
|
+
if request.url.path == "/api/accounts/auth/logout/":
|
|
27
|
+
return httpx.Response(200, json={"detail": "Successfully logged out."})
|
|
28
|
+
return httpx.Response(404, json={"detail": "Not found."})
|
|
29
|
+
|
|
30
|
+
return handler
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_login_with_debug_token(cli, runner, transport_factory):
|
|
34
|
+
transport = transport_factory(make_auth_handler())
|
|
35
|
+
|
|
36
|
+
result = runner.invoke(cli, ["login", "dev@example.com"])
|
|
37
|
+
|
|
38
|
+
assert result.exit_code == 0, result.output
|
|
39
|
+
assert config.get("token") == "drf-token"
|
|
40
|
+
assert config.get("email") == "dev@example.com"
|
|
41
|
+
magic, verify = transport.requests
|
|
42
|
+
assert json.loads(magic.content) == {"email": "dev@example.com"}
|
|
43
|
+
assert json.loads(verify.content) == {"token": "magic-1"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_login_prompts_without_debug_token(cli, runner, transport_factory):
|
|
47
|
+
transport_factory(make_auth_handler(debug_token=None))
|
|
48
|
+
|
|
49
|
+
result = runner.invoke(cli, ["login", "dev@example.com"], input="magic-1\n")
|
|
50
|
+
|
|
51
|
+
assert result.exit_code == 0, result.output
|
|
52
|
+
assert config.get("token") == "drf-token"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_login_bad_token_fails(cli, runner, transport_factory):
|
|
56
|
+
transport_factory(make_auth_handler(debug_token="wrong"))
|
|
57
|
+
|
|
58
|
+
result = runner.invoke(cli, ["login", "dev@example.com"])
|
|
59
|
+
|
|
60
|
+
assert result.exit_code == 1
|
|
61
|
+
assert config.get("token") is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_whoami_sends_token(cli, runner, transport_factory):
|
|
65
|
+
config.set_value("token", "drf-token")
|
|
66
|
+
transport = transport_factory(make_auth_handler())
|
|
67
|
+
|
|
68
|
+
result = runner.invoke(cli, ["whoami"])
|
|
69
|
+
|
|
70
|
+
assert result.exit_code == 0, result.output
|
|
71
|
+
assert transport.requests[0].headers["Authorization"] == "Token drf-token"
|
|
72
|
+
assert json.loads(result.output) == {"email": "dev@example.com"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_logout_clears_token(cli, runner, transport_factory):
|
|
76
|
+
config.set_value("token", "drf-token")
|
|
77
|
+
transport = transport_factory(make_auth_handler())
|
|
78
|
+
|
|
79
|
+
result = runner.invoke(cli, ["logout"])
|
|
80
|
+
|
|
81
|
+
assert result.exit_code == 0, result.output
|
|
82
|
+
assert config.get("token") is None
|
|
83
|
+
assert transport.requests[0].url.path == "/api/accounts/auth/logout/"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_config_set_and_get(cli, runner):
|
|
87
|
+
result = runner.invoke(cli, ["config", "set", "default_workspace", "ws-1"])
|
|
88
|
+
assert result.exit_code == 0, result.output
|
|
89
|
+
|
|
90
|
+
result = runner.invoke(cli, ["config", "get", "default_workspace"])
|
|
91
|
+
assert result.exit_code == 0
|
|
92
|
+
assert result.output.strip() == "ws-1"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def make_mfa_handler(*, enrolled: bool, code: str = "123456", ticket_valid: bool = True):
|
|
96
|
+
"""The API's MFA login branch: verify answers with a ticket instead of a
|
|
97
|
+
token, and a code (after enrollment, when not enrolled) completes it."""
|
|
98
|
+
|
|
99
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
100
|
+
path = request.url.path
|
|
101
|
+
body = json.loads(request.content) if request.content else {}
|
|
102
|
+
if path == "/api/accounts/auth/magic-link/":
|
|
103
|
+
return httpx.Response(200, json={"detail": "Magic link sent."})
|
|
104
|
+
if path == "/api/accounts/auth/verify/":
|
|
105
|
+
if body["token"] != "magic-1":
|
|
106
|
+
return httpx.Response(400, json={"detail": "Invalid or expired link."})
|
|
107
|
+
return httpx.Response(
|
|
108
|
+
200, json={"mfa_required": True, "mfa_enrolled": enrolled, "ticket": "ticket-1"}
|
|
109
|
+
)
|
|
110
|
+
if not ticket_valid or body.get("ticket") != "ticket-1":
|
|
111
|
+
status = 400 if path.endswith("/verify/") else 403
|
|
112
|
+
return httpx.Response(status, json={"detail": "Invalid or expired ticket."})
|
|
113
|
+
if path == "/api/accounts/auth/mfa/setup/":
|
|
114
|
+
return httpx.Response(
|
|
115
|
+
200,
|
|
116
|
+
json={"secret": "SECRET", "otpauth_url": "otpauth://totp/Current?secret=SECRET"},
|
|
117
|
+
)
|
|
118
|
+
if path in ("/api/accounts/auth/mfa/verify/", "/api/accounts/auth/mfa/confirm/"):
|
|
119
|
+
if body["code"] != code:
|
|
120
|
+
return httpx.Response(400, json={"detail": "Incorrect code."})
|
|
121
|
+
return httpx.Response(
|
|
122
|
+
200,
|
|
123
|
+
json={
|
|
124
|
+
"mfa_required": False,
|
|
125
|
+
"token": "drf-token",
|
|
126
|
+
"user": {"email": "a@orinlabs.ai"},
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
return httpx.Response(404, json={"detail": "Not found."})
|
|
130
|
+
|
|
131
|
+
return handler
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_login_with_enrolled_mfa(cli, runner, transport_factory):
|
|
135
|
+
transport = transport_factory(make_mfa_handler(enrolled=True))
|
|
136
|
+
|
|
137
|
+
result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123 456\n")
|
|
138
|
+
|
|
139
|
+
assert result.exit_code == 0, result.output
|
|
140
|
+
assert config.get("token") == "drf-token"
|
|
141
|
+
paths = [r.url.path for r in transport.requests]
|
|
142
|
+
assert paths[-1] == "/api/accounts/auth/mfa/verify/"
|
|
143
|
+
assert json.loads(transport.requests[-1].content) == {"ticket": "ticket-1", "code": "123456"}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_login_enrolls_mfa_when_not_enrolled(cli, runner, transport_factory):
|
|
147
|
+
transport = transport_factory(make_mfa_handler(enrolled=False))
|
|
148
|
+
|
|
149
|
+
result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123456\n")
|
|
150
|
+
|
|
151
|
+
assert result.exit_code == 0, result.output
|
|
152
|
+
assert "otpauth://totp/Current?secret=SECRET" in result.output
|
|
153
|
+
assert "SECRET" in result.output
|
|
154
|
+
assert config.get("token") == "drf-token"
|
|
155
|
+
paths = [r.url.path for r in transport.requests]
|
|
156
|
+
assert paths[-2:] == ["/api/accounts/auth/mfa/setup/", "/api/accounts/auth/mfa/confirm/"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_login_retries_incorrect_code(cli, runner, transport_factory):
|
|
160
|
+
transport_factory(make_mfa_handler(enrolled=True))
|
|
161
|
+
|
|
162
|
+
result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n000000\n123456\n")
|
|
163
|
+
|
|
164
|
+
assert result.exit_code == 0, result.output
|
|
165
|
+
assert "Incorrect code. Try again." in result.output
|
|
166
|
+
assert config.get("token") == "drf-token"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_login_gives_up_after_three_incorrect_codes(cli, runner, transport_factory):
|
|
170
|
+
transport = transport_factory(make_mfa_handler(enrolled=True))
|
|
171
|
+
|
|
172
|
+
result = runner.invoke(
|
|
173
|
+
cli, ["login", "a@orinlabs.ai"], input="magic-1\n000000\n000000\n000000\n"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert result.exit_code == 1
|
|
177
|
+
assert config.get("token") is None
|
|
178
|
+
verifies = [r for r in transport.requests if r.url.path == "/api/accounts/auth/mfa/verify/"]
|
|
179
|
+
assert len(verifies) == 3
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_login_expired_ticket_says_to_start_again(cli, runner, transport_factory):
|
|
183
|
+
transport_factory(make_mfa_handler(enrolled=True, ticket_valid=False))
|
|
184
|
+
|
|
185
|
+
result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123456\n")
|
|
186
|
+
|
|
187
|
+
assert result.exit_code == 1
|
|
188
|
+
assert "Run `current login` again." in result.output
|
|
189
|
+
assert config.get("token") is None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_login_expired_ticket_during_enrollment(cli, runner, transport_factory):
|
|
193
|
+
transport_factory(make_mfa_handler(enrolled=False, ticket_valid=False))
|
|
194
|
+
|
|
195
|
+
result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n")
|
|
196
|
+
|
|
197
|
+
assert result.exit_code == 1
|
|
198
|
+
assert "Run `current login` again." in result.output
|
|
199
|
+
assert config.get("token") is None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_login_accepts_pasted_sign_in_link(cli, runner, transport_factory):
|
|
203
|
+
transport = transport_factory(make_auth_handler(debug_token=None))
|
|
204
|
+
|
|
205
|
+
result = runner.invoke(
|
|
206
|
+
cli,
|
|
207
|
+
["login", "dev@example.com"],
|
|
208
|
+
input="https://current.orinlabs.ai/auth/verify?token=magic-1\n",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert result.exit_code == 0, result.output
|
|
212
|
+
assert json.loads(transport.requests[1].content) == {"token": "magic-1"}
|
|
213
|
+
assert config.get("token") == "drf-token"
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""The login flow must match the API's magic-link contract."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
|
|
7
|
-
from current_cli import config
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def make_auth_handler(debug_token: str | None = "magic-1"):
|
|
11
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
12
|
-
if request.url.path == "/api/accounts/auth/magic-link/":
|
|
13
|
-
payload = {"detail": "Magic link sent."}
|
|
14
|
-
if debug_token:
|
|
15
|
-
payload["debug_token"] = debug_token
|
|
16
|
-
return httpx.Response(200, json=payload)
|
|
17
|
-
if request.url.path == "/api/accounts/auth/verify/":
|
|
18
|
-
body = json.loads(request.content)
|
|
19
|
-
if body["token"] != "magic-1":
|
|
20
|
-
return httpx.Response(400, json={"detail": "Invalid or expired link."})
|
|
21
|
-
return httpx.Response(
|
|
22
|
-
200, json={"token": "drf-token", "user": {"email": "dev@example.com"}}
|
|
23
|
-
)
|
|
24
|
-
if request.url.path == "/api/accounts/auth/me/":
|
|
25
|
-
return httpx.Response(200, json={"email": "dev@example.com"})
|
|
26
|
-
if request.url.path == "/api/accounts/auth/logout/":
|
|
27
|
-
return httpx.Response(200, json={"detail": "Successfully logged out."})
|
|
28
|
-
return httpx.Response(404, json={"detail": "Not found."})
|
|
29
|
-
|
|
30
|
-
return handler
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_login_with_debug_token(cli, runner, transport_factory):
|
|
34
|
-
transport = transport_factory(make_auth_handler())
|
|
35
|
-
|
|
36
|
-
result = runner.invoke(cli, ["login", "dev@example.com"])
|
|
37
|
-
|
|
38
|
-
assert result.exit_code == 0, result.output
|
|
39
|
-
assert config.get("token") == "drf-token"
|
|
40
|
-
assert config.get("email") == "dev@example.com"
|
|
41
|
-
magic, verify = transport.requests
|
|
42
|
-
assert json.loads(magic.content) == {"email": "dev@example.com"}
|
|
43
|
-
assert json.loads(verify.content) == {"token": "magic-1"}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_login_prompts_without_debug_token(cli, runner, transport_factory):
|
|
47
|
-
transport_factory(make_auth_handler(debug_token=None))
|
|
48
|
-
|
|
49
|
-
result = runner.invoke(cli, ["login", "dev@example.com"], input="magic-1\n")
|
|
50
|
-
|
|
51
|
-
assert result.exit_code == 0, result.output
|
|
52
|
-
assert config.get("token") == "drf-token"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_login_bad_token_fails(cli, runner, transport_factory):
|
|
56
|
-
transport_factory(make_auth_handler(debug_token="wrong"))
|
|
57
|
-
|
|
58
|
-
result = runner.invoke(cli, ["login", "dev@example.com"])
|
|
59
|
-
|
|
60
|
-
assert result.exit_code == 1
|
|
61
|
-
assert config.get("token") is None
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_whoami_sends_token(cli, runner, transport_factory):
|
|
65
|
-
config.set_value("token", "drf-token")
|
|
66
|
-
transport = transport_factory(make_auth_handler())
|
|
67
|
-
|
|
68
|
-
result = runner.invoke(cli, ["whoami"])
|
|
69
|
-
|
|
70
|
-
assert result.exit_code == 0, result.output
|
|
71
|
-
assert transport.requests[0].headers["Authorization"] == "Token drf-token"
|
|
72
|
-
assert json.loads(result.output) == {"email": "dev@example.com"}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_logout_clears_token(cli, runner, transport_factory):
|
|
76
|
-
config.set_value("token", "drf-token")
|
|
77
|
-
transport = transport_factory(make_auth_handler())
|
|
78
|
-
|
|
79
|
-
result = runner.invoke(cli, ["logout"])
|
|
80
|
-
|
|
81
|
-
assert result.exit_code == 0, result.output
|
|
82
|
-
assert config.get("token") is None
|
|
83
|
-
assert transport.requests[0].url.path == "/api/accounts/auth/logout/"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_config_set_and_get(cli, runner):
|
|
87
|
-
result = runner.invoke(cli, ["config", "set", "default_workspace", "ws-1"])
|
|
88
|
-
assert result.exit_code == 0, result.output
|
|
89
|
-
|
|
90
|
-
result = runner.invoke(cli, ["config", "get", "default_workspace"])
|
|
91
|
-
assert result.exit_code == 0
|
|
92
|
-
assert result.output.strip() == "ws-1"
|
|
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
|