current-cli 0.1.59__tar.gz → 0.1.61__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 (22) hide show
  1. {current_cli-0.1.59 → current_cli-0.1.61}/PKG-INFO +8 -1
  2. {current_cli-0.1.59 → current_cli-0.1.61}/README.md +7 -0
  3. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/docs.py +5 -0
  4. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/main.py +83 -2
  5. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/schema.yml +2 -0
  6. {current_cli-0.1.59 → current_cli-0.1.61}/llms.txt +7 -1
  7. {current_cli-0.1.59 → current_cli-0.1.61}/pyproject.toml +1 -1
  8. current_cli-0.1.61/tests/test_auth.py +213 -0
  9. current_cli-0.1.59/tests/test_auth.py +0 -92
  10. {current_cli-0.1.59 → current_cli-0.1.61}/.gitignore +0 -0
  11. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/__init__.py +0 -0
  12. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/build.py +0 -0
  13. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/config.py +0 -0
  14. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/http.py +0 -0
  15. {current_cli-0.1.59 → current_cli-0.1.61}/current_cli/schema.py +0 -0
  16. {current_cli-0.1.59 → current_cli-0.1.61}/scripts/bump_version.py +0 -0
  17. {current_cli-0.1.59 → current_cli-0.1.61}/tests/__init__.py +0 -0
  18. {current_cli-0.1.59 → current_cli-0.1.61}/tests/conftest.py +0 -0
  19. {current_cli-0.1.59 → current_cli-0.1.61}/tests/test_bump_version.py +0 -0
  20. {current_cli-0.1.59 → current_cli-0.1.61}/tests/test_command_tree.py +0 -0
  21. {current_cli-0.1.59 → current_cli-0.1.61}/tests/test_docs.py +0 -0
  22. {current_cli-0.1.59 → current_cli-0.1.61}/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.59
3
+ Version: 0.1.61
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 store the auth token."""
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 token from the sign-in link")
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}.")
@@ -6409,6 +6409,7 @@ components:
6409
6409
  - sharepoint
6410
6410
  - notion
6411
6411
  - salesforce
6412
+ - adobe_sign
6412
6413
  type: string
6413
6414
  description: |-
6414
6415
  * `basecamp` - Basecamp
@@ -6417,6 +6418,7 @@ components:
6417
6418
  * `sharepoint` - SharePoint
6418
6419
  * `notion` - Notion
6419
6420
  * `salesforce` - Salesforce
6421
+ * `adobe_sign` - Adobe Sign
6420
6422
  InviteDetailResponse:
6421
6423
  type: object
6422
6424
  properties:
@@ -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 store the auth token.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "current-cli"
3
- version = "0.1.59"
3
+ version = "0.1.61"
4
4
  description = "Command line interface for the Current API, generated from its OpenAPI schema at runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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