albus-cli 0.7.0__tar.gz → 0.8.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.
- {albus_cli-0.7.0 → albus_cli-0.8.0}/PKG-INFO +2 -2
- {albus_cli-0.7.0 → albus_cli-0.8.0}/pyproject.toml +2 -2
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/client.py +67 -30
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/auth.py +4 -2
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/organization.py +32 -1
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/credentials.py +122 -7
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/main.py +5 -1
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_auth.py +8 -9
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_billing.py +1 -2
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_cli.py +26 -1
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_credentials.py +118 -1
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_health.py +0 -1
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_invites.py +2 -3
- albus_cli-0.8.0/tests/test_organization.py +165 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_tokens.py +1 -2
- {albus_cli-0.7.0 → albus_cli-0.8.0}/uv.lock +5 -5
- albus_cli-0.7.0/tests/test_organization.py +0 -59
- {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/pull_request_template.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/workflows/close-pull-requests.yml +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/.gitignore +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/AGENTS.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/CONTRIBUTING.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/LICENSE +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/Makefile +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/README.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/RELEASING.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/SECURITY.md +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/install.ps1 +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/install.sh +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/pyrightconfig.json +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/setup.sh +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/__init__.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/__main__.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/__init__.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/agents.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/billing.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/invites.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/memories.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/models.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/secrets.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/sessions.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/status.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/tokens.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/traces.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/context.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/docs.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/oauth.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/output.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/__init__.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/conftest.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_bump_sdk.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_memories.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_models.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_oauth.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_release_scripts.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_status.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_traces.py +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/bump-sdk +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/check +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/mirror-release +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/publish +0 -0
- {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/release_state.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: albus-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Command-line client for the Albus REST API
|
|
5
5
|
Project-URL: Homepage, https://github.com/albusgroup/albus-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/albusgroup/albus-cli
|
|
@@ -9,7 +9,7 @@ Author: Albus
|
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
|
-
Requires-Dist: albus-sdk==0.
|
|
12
|
+
Requires-Dist: albus-sdk==0.16.0
|
|
13
13
|
Requires-Dist: typer<0.28,>=0.26.8
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "albus-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Command-line client for the Albus REST API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -13,7 +13,7 @@ authors = [{ name = "Albus" }]
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
# Pinned, not floated: the published CLI states the one SDK version it was
|
|
15
15
|
# tested against, and bumping it is a reviewed edit made when releasing.
|
|
16
|
-
"albus-sdk==0.
|
|
16
|
+
"albus-sdk==0.16.0",
|
|
17
17
|
"typer>=0.26.8,<0.28",
|
|
18
18
|
]
|
|
19
19
|
|
|
@@ -45,6 +45,17 @@ class _Used:
|
|
|
45
45
|
_used: _Used | None = None
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Selection:
|
|
50
|
+
"""A client on the browser session and the session it was read
|
|
51
|
+
from, for `organization use` and `default`: what they save
|
|
52
|
+
afterwards must be keyed to the sign-in they validated, not
|
|
53
|
+
whichever one is stored by then."""
|
|
54
|
+
|
|
55
|
+
api: Albus
|
|
56
|
+
session: credentials.Session
|
|
57
|
+
|
|
58
|
+
|
|
48
59
|
def base_url(configured: str | None) -> str:
|
|
49
60
|
"""The API a command talks to, spelled as the credential store keys
|
|
50
61
|
it. The SDK picks its first server when none is configured."""
|
|
@@ -67,15 +78,13 @@ def client(
|
|
|
67
78
|
if key:
|
|
68
79
|
# The environment wins over a stored session deliberately, so CI
|
|
69
80
|
# and agent harnesses inject a credential without touching disk.
|
|
70
|
-
return _client(base_url, timeout, organization, api_key=
|
|
81
|
+
return _client(base_url, timeout, organization, key, api_key=True)
|
|
71
82
|
|
|
72
|
-
stored = credentials.
|
|
83
|
+
stored = credentials.session(base_url)
|
|
73
84
|
if stored is None:
|
|
74
85
|
raise _signed_out(base_url)
|
|
75
86
|
|
|
76
|
-
return
|
|
77
|
-
base_url, timeout, organization, _current(base_url, stored)
|
|
78
|
-
)
|
|
87
|
+
return _session_client(base_url, timeout, organization, stored)
|
|
79
88
|
|
|
80
89
|
|
|
81
90
|
def signed_in_client(
|
|
@@ -85,16 +94,21 @@ def signed_in_client(
|
|
|
85
94
|
only a human bearer token for use it directly: an API key is a 401
|
|
86
95
|
there, and the environment winning would be a 401 nobody asked
|
|
87
96
|
for."""
|
|
88
|
-
stored =
|
|
89
|
-
|
|
90
|
-
raise NotSignedIn(
|
|
91
|
-
f"not signed in to {base_url}. This command needs a browser "
|
|
92
|
-
f"session, which {API_KEY_ENV} cannot stand in for: run "
|
|
93
|
-
"`albus login`."
|
|
94
|
-
)
|
|
97
|
+
stored = _signed_in(base_url)
|
|
98
|
+
return _session_client(base_url, timeout, organization, stored)
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
|
|
101
|
+
def selection(
|
|
102
|
+
base_url: str, timeout: float | None, organization: str | None
|
|
103
|
+
) -> Selection:
|
|
104
|
+
"""A client on the browser session acting in exactly `organization`
|
|
105
|
+
— none meaning the server's default, not the saved one. The
|
|
106
|
+
organization commands read `/whoami` through it before they write,
|
|
107
|
+
so a request that fails leaves the selection as it was."""
|
|
108
|
+
stored = _signed_in(base_url)
|
|
109
|
+
current = _current(base_url, stored.credential)
|
|
110
|
+
return Selection(
|
|
111
|
+
bearer_client(base_url, timeout, organization, current), stored
|
|
98
112
|
)
|
|
99
113
|
|
|
100
114
|
|
|
@@ -120,7 +134,7 @@ def bearer_client(
|
|
|
120
134
|
"""A client on one credential, for `login` reading back the session
|
|
121
135
|
it just stored rather than whatever precedence would pick."""
|
|
122
136
|
return _client(
|
|
123
|
-
base_url, timeout, organization,
|
|
137
|
+
base_url, timeout, organization, credential.access_token, api_key=False
|
|
124
138
|
)
|
|
125
139
|
|
|
126
140
|
|
|
@@ -197,6 +211,34 @@ def shadows_logout() -> str | None:
|
|
|
197
211
|
return _shadowing("commands stay authenticated until it is unset.")
|
|
198
212
|
|
|
199
213
|
|
|
214
|
+
def _signed_in(base_url: str) -> credentials.Session:
|
|
215
|
+
stored = credentials.session(base_url)
|
|
216
|
+
if stored is None:
|
|
217
|
+
raise NotSignedIn(
|
|
218
|
+
f"not signed in to {base_url}. This command needs a browser "
|
|
219
|
+
f"session, which {API_KEY_ENV} cannot stand in for: run "
|
|
220
|
+
"`albus login`."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return stored
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _session_client(
|
|
227
|
+
base_url: str,
|
|
228
|
+
timeout: float | None,
|
|
229
|
+
organization: str | None,
|
|
230
|
+
stored: credentials.Session,
|
|
231
|
+
) -> Albus:
|
|
232
|
+
"""A client on a stored session, acting in the organization named on
|
|
233
|
+
the command line, else the one saved with the session."""
|
|
234
|
+
return bearer_client(
|
|
235
|
+
base_url,
|
|
236
|
+
timeout,
|
|
237
|
+
organization or stored.organization,
|
|
238
|
+
_current(base_url, stored.credential),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
200
242
|
def _signed_out(base_url: str) -> NotSignedIn:
|
|
201
243
|
"""Nothing stored to authenticate with, whether this command found
|
|
202
244
|
no entry or a concurrent `logout` removed it."""
|
|
@@ -226,33 +268,28 @@ def _client(
|
|
|
226
268
|
base_url: str,
|
|
227
269
|
timeout: float | None,
|
|
228
270
|
organization: str | None,
|
|
271
|
+
credential: str,
|
|
229
272
|
*,
|
|
230
|
-
api_key:
|
|
231
|
-
access_token: str | None = None,
|
|
273
|
+
api_key: bool,
|
|
232
274
|
) -> Albus:
|
|
275
|
+
"""A client sending `credential`, which is an API key or a session's
|
|
276
|
+
access token: both are a bearer token on the wire, and the SDK takes
|
|
277
|
+
either as its one `api_key`. Which it was is what `rejected` needs
|
|
278
|
+
to know."""
|
|
233
279
|
global _used
|
|
234
|
-
_used = _Used(base_url=base_url, api_key=api_key
|
|
235
|
-
return _built(
|
|
236
|
-
base_url,
|
|
237
|
-
timeout,
|
|
238
|
-
organization,
|
|
239
|
-
api_key=api_key,
|
|
240
|
-
access_token=access_token,
|
|
241
|
-
)
|
|
280
|
+
_used = _Used(base_url=base_url, api_key=api_key)
|
|
281
|
+
return _built(base_url, timeout, organization, credential)
|
|
242
282
|
|
|
243
283
|
|
|
244
284
|
def _built(
|
|
245
285
|
base_url: str,
|
|
246
286
|
timeout: float | None,
|
|
247
287
|
organization: str | None,
|
|
248
|
-
|
|
249
|
-
api_key: str | None = None,
|
|
250
|
-
access_token: str | None = None,
|
|
288
|
+
credential: str | None = None,
|
|
251
289
|
) -> Albus:
|
|
252
290
|
headers = {ORGANIZATION_HEADER: organization} if organization else {}
|
|
253
291
|
return Albus(
|
|
254
|
-
api_key=
|
|
255
|
-
access_token=access_token,
|
|
292
|
+
api_key=credential,
|
|
256
293
|
server_url=base_url,
|
|
257
294
|
client=httpx.Client(
|
|
258
295
|
follow_redirects=True, timeout=timeout, headers=headers
|
|
@@ -45,8 +45,8 @@ def login(ctx: typer.Context, no_browser: NoBrowser = False) -> None:
|
|
|
45
45
|
)
|
|
46
46
|
# A browser session authenticates a user, so `/whoami` names one;
|
|
47
47
|
# an account it did not name is not one to claim under "Signed in".
|
|
48
|
-
|
|
49
|
-
output.done(_signed_in(user))
|
|
48
|
+
caller = signed_in.auth.whoami()
|
|
49
|
+
output.done(_signed_in(caller.user))
|
|
50
50
|
output.field("API", api)
|
|
51
51
|
output.field("Credential", output.abbreviated(credentials.path()))
|
|
52
52
|
if session.refresh_token is None:
|
|
@@ -55,6 +55,8 @@ def login(ctx: typer.Context, no_browser: NoBrowser = False) -> None:
|
|
|
55
55
|
"access token expires: enable Allow Offline Access on the API."
|
|
56
56
|
)
|
|
57
57
|
|
|
58
|
+
output.emit(caller)
|
|
59
|
+
|
|
58
60
|
_also(client.shadows_session())
|
|
59
61
|
_next_steps()
|
|
60
62
|
|
|
@@ -5,7 +5,8 @@ from typing import Annotated, Literal
|
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
|
-
from albus_cli
|
|
8
|
+
from albus_cli import client, credentials
|
|
9
|
+
from albus_cli.context import base_url, sdk, timeout
|
|
9
10
|
from albus_cli.output import emit
|
|
10
11
|
|
|
11
12
|
app = typer.Typer(
|
|
@@ -25,6 +26,36 @@ def get(ctx: typer.Context) -> None:
|
|
|
25
26
|
emit(sdk(ctx).organization.get_organization())
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
@app.command("use")
|
|
30
|
+
def use(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
organization_id: Annotated[
|
|
33
|
+
str, typer.Argument(metavar="ORGANIZATION_ID", help="Organization ID.")
|
|
34
|
+
],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Persist the organization the browser session acts in."""
|
|
37
|
+
if not organization_id:
|
|
38
|
+
raise typer.BadParameter("ORGANIZATION_ID must not be empty.")
|
|
39
|
+
|
|
40
|
+
_select(ctx, organization_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("default")
|
|
44
|
+
def default(ctx: typer.Context) -> None:
|
|
45
|
+
"""Use the earliest-joined organization by default."""
|
|
46
|
+
_select(ctx, None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _select(ctx: typer.Context, organization_id: str | None) -> None:
|
|
50
|
+
"""Prove the membership with `/whoami`, then save it against the
|
|
51
|
+
session that proved it."""
|
|
52
|
+
api = base_url(ctx)
|
|
53
|
+
selected = client.selection(api, timeout(ctx), organization_id)
|
|
54
|
+
caller = selected.api.auth.whoami()
|
|
55
|
+
credentials.set_organization(api, organization_id, selected.session)
|
|
56
|
+
emit(caller)
|
|
57
|
+
|
|
58
|
+
|
|
28
59
|
@app.command("rename")
|
|
29
60
|
def rename(
|
|
30
61
|
ctx: typer.Context,
|
|
@@ -9,6 +9,7 @@ and a half-written file would look like a broken login.
|
|
|
9
9
|
import fcntl
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import secrets
|
|
12
13
|
import tempfile
|
|
13
14
|
from collections.abc import Callable, Iterator
|
|
14
15
|
from contextlib import contextmanager
|
|
@@ -22,6 +23,10 @@ FILE_NAME = "credentials.json"
|
|
|
22
23
|
VERSION = 1
|
|
23
24
|
FILE_MODE = 0o600
|
|
24
25
|
DIRECTORY_MODE = 0o700
|
|
26
|
+
ORGANIZATION_ID = "organization_id"
|
|
27
|
+
# Names one sign-in. Renewal rotates the tokens but keeps this, so it
|
|
28
|
+
# is what tells a refreshed session from a replaced one.
|
|
29
|
+
SESSION_ID = "session_id"
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
class CorruptFile(Exception):
|
|
@@ -31,6 +36,17 @@ class CorruptFile(Exception):
|
|
|
31
36
|
super().__init__(f"{file} is not a credentials file: {reason}")
|
|
32
37
|
|
|
33
38
|
|
|
39
|
+
class SessionReplaced(Exception):
|
|
40
|
+
"""The stored session changed under a command that had validated it:
|
|
41
|
+
a concurrent `login` or `logout` for the same API."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base_url: str) -> None:
|
|
44
|
+
super().__init__(
|
|
45
|
+
f"the session for {base_url} changed while this command ran. "
|
|
46
|
+
"Run it again."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
@dataclass(frozen=True)
|
|
35
51
|
class Credential:
|
|
36
52
|
access_token: str
|
|
@@ -40,36 +56,102 @@ class Credential:
|
|
|
40
56
|
expires_at: float
|
|
41
57
|
|
|
42
58
|
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Session:
|
|
61
|
+
"""A stored browser session: the credential and the organization it
|
|
62
|
+
acts in, read from one version of the entry. Read separately, a
|
|
63
|
+
concurrent sign-in could pair one account's token with another's
|
|
64
|
+
organization."""
|
|
65
|
+
|
|
66
|
+
credential: Credential
|
|
67
|
+
organization: str | None
|
|
68
|
+
# None for an entry written before sign-ins were named.
|
|
69
|
+
identity: str | None
|
|
70
|
+
|
|
71
|
+
|
|
43
72
|
def path() -> Path:
|
|
44
73
|
"""The credentials file the CLI reads first and always writes."""
|
|
45
74
|
return _directory() / FILE_NAME
|
|
46
75
|
|
|
47
76
|
|
|
48
77
|
def load(base_url: str) -> Credential | None:
|
|
49
|
-
"""The stored credential for base_url, or None if there is none.
|
|
78
|
+
"""The stored credential for base_url, or None if there is none."""
|
|
79
|
+
stored = session(base_url)
|
|
80
|
+
return stored.credential if stored else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def session(base_url: str) -> Session | None:
|
|
84
|
+
"""The stored session for base_url, or None if there is none.
|
|
50
85
|
|
|
51
86
|
A file or entry this version cannot read counts as "none": the
|
|
52
87
|
caller then tells the user to sign in, which is the fix either way.
|
|
53
88
|
Readers take no lock because every write lands by rename.
|
|
54
89
|
"""
|
|
55
|
-
primary = path()
|
|
56
|
-
file = primary if primary.exists() else _legacy_path()
|
|
57
90
|
try:
|
|
58
|
-
|
|
91
|
+
entry = _stored_entries().get(_key(base_url))
|
|
59
92
|
except (OSError, CorruptFile):
|
|
60
93
|
return None
|
|
61
94
|
|
|
62
|
-
|
|
95
|
+
credential = _credential(entry)
|
|
96
|
+
if credential is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
return Session(credential, _selected_organization(entry), _identity(entry))
|
|
63
100
|
|
|
64
101
|
|
|
65
102
|
def save(base_url: str, credential: Credential) -> None:
|
|
66
|
-
|
|
103
|
+
"""Store a fresh sign-in, under a name no earlier sign-in had."""
|
|
104
|
+
_replace_entry(
|
|
105
|
+
base_url, {**asdict(credential), SESSION_ID: secrets.token_hex(16)}
|
|
106
|
+
)
|
|
67
107
|
|
|
68
108
|
|
|
69
109
|
def delete(base_url: str) -> None:
|
|
70
110
|
_replace_entry(base_url, None)
|
|
71
111
|
|
|
72
112
|
|
|
113
|
+
def organization(base_url: str) -> str | None:
|
|
114
|
+
"""The browser session's saved organization, if it still has one."""
|
|
115
|
+
stored = session(base_url)
|
|
116
|
+
return stored.organization if stored else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_organization(
|
|
120
|
+
base_url: str, organization_id: str | None, validated: Session
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Remember or clear the selected organization for one browser session.
|
|
123
|
+
|
|
124
|
+
`validated` is the session the caller proved the membership with; the
|
|
125
|
+
write goes through only while the entry is still that sign-in, so
|
|
126
|
+
one that landed in between does not inherit another account's
|
|
127
|
+
organization. A renewal in between is the same sign-in and does not
|
|
128
|
+
stand in the way. A session still living in the legacy file is carried
|
|
129
|
+
into the primary one with its neighbours, since that is the only
|
|
130
|
+
file this version writes.
|
|
131
|
+
"""
|
|
132
|
+
target = path()
|
|
133
|
+
directory = target.parent
|
|
134
|
+
directory.mkdir(parents=True, exist_ok=True, mode=DIRECTORY_MODE)
|
|
135
|
+
directory.chmod(DIRECTORY_MODE)
|
|
136
|
+
|
|
137
|
+
with _lock(directory):
|
|
138
|
+
entries = _stored_entries()
|
|
139
|
+
fields = _mapping(entries.get(_key(base_url)))
|
|
140
|
+
if fields is None or _credential(fields) is None:
|
|
141
|
+
raise SessionReplaced(base_url)
|
|
142
|
+
|
|
143
|
+
if _identity(fields) != validated.identity:
|
|
144
|
+
raise SessionReplaced(base_url)
|
|
145
|
+
|
|
146
|
+
if organization_id is None:
|
|
147
|
+
fields.pop(ORGANIZATION_ID, None)
|
|
148
|
+
else:
|
|
149
|
+
fields[ORGANIZATION_ID] = organization_id
|
|
150
|
+
|
|
151
|
+
entries[_key(base_url)] = fields
|
|
152
|
+
_write(target, {"version": VERSION, "credentials": entries})
|
|
153
|
+
|
|
154
|
+
|
|
73
155
|
def renew(
|
|
74
156
|
base_url: str, mint: Callable[[Credential], Credential]
|
|
75
157
|
) -> Credential | None:
|
|
@@ -102,7 +184,16 @@ def renew(
|
|
|
102
184
|
if minted == current:
|
|
103
185
|
return current
|
|
104
186
|
|
|
105
|
-
|
|
187
|
+
entry = asdict(minted)
|
|
188
|
+
stored = entries.get(_key(base_url))
|
|
189
|
+
for name, kept in (
|
|
190
|
+
(ORGANIZATION_ID, _selected_organization(stored)),
|
|
191
|
+
(SESSION_ID, _identity(stored)),
|
|
192
|
+
):
|
|
193
|
+
if kept is not None:
|
|
194
|
+
entry[name] = kept
|
|
195
|
+
|
|
196
|
+
entries[_key(base_url)] = entry
|
|
106
197
|
_write(target, {"version": VERSION, "credentials": entries})
|
|
107
198
|
return minted
|
|
108
199
|
|
|
@@ -124,6 +215,13 @@ def _legacy_path() -> Path:
|
|
|
124
215
|
return Path.home() / ".albus" / FILE_NAME
|
|
125
216
|
|
|
126
217
|
|
|
218
|
+
def _stored_entries() -> dict[str, object]:
|
|
219
|
+
"""The entries of the file a reader sees: the primary one, or the
|
|
220
|
+
legacy one while the primary has never been written."""
|
|
221
|
+
primary = path()
|
|
222
|
+
return _entries(primary if primary.exists() else _legacy_path())
|
|
223
|
+
|
|
224
|
+
|
|
127
225
|
def _key(base_url: str) -> str:
|
|
128
226
|
return base_url.rstrip("/")
|
|
129
227
|
|
|
@@ -238,6 +336,23 @@ def _credential(entry: object) -> Credential | None:
|
|
|
238
336
|
return Credential(access_token, refresh_token, float(expires_at))
|
|
239
337
|
|
|
240
338
|
|
|
339
|
+
def _selected_organization(entry: object) -> str | None:
|
|
340
|
+
return _text(entry, ORGANIZATION_ID)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _identity(entry: object) -> str | None:
|
|
344
|
+
return _text(entry, SESSION_ID)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _text(entry: object, name: str) -> str | None:
|
|
348
|
+
fields = _mapping(entry)
|
|
349
|
+
if fields is None:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
value = fields.get(name)
|
|
353
|
+
return value if isinstance(value, str) else None
|
|
354
|
+
|
|
355
|
+
|
|
241
356
|
def _mapping(value: object) -> dict[str, object] | None:
|
|
242
357
|
if not isinstance(value, dict):
|
|
243
358
|
return None
|
|
@@ -119,7 +119,11 @@ def health(ctx: typer.Context) -> None:
|
|
|
119
119
|
def main() -> None:
|
|
120
120
|
try:
|
|
121
121
|
app()
|
|
122
|
-
except (
|
|
122
|
+
except (
|
|
123
|
+
NotSignedIn,
|
|
124
|
+
oauth.LoginError,
|
|
125
|
+
credentials.SessionReplaced,
|
|
126
|
+
) as unauthenticated:
|
|
123
127
|
fail(str(unauthenticated))
|
|
124
128
|
except credentials.CorruptFile as corrupt:
|
|
125
129
|
fail(f"{corrupt}. Delete or repair it, then run `albus login`.")
|
|
@@ -75,7 +75,6 @@ def test_api_key_wins_over_a_stored_session(
|
|
|
75
75
|
|
|
76
76
|
assert result.exit_code == 0, result.output
|
|
77
77
|
assert albus.init_kwargs[0]["api_key"] == "env-key"
|
|
78
|
-
assert albus.init_kwargs[0]["access_token"] is None
|
|
79
78
|
|
|
80
79
|
|
|
81
80
|
def test_stored_session_authenticates_with_a_bearer_token(
|
|
@@ -86,8 +85,7 @@ def test_stored_session_authenticates_with_a_bearer_token(
|
|
|
86
85
|
result = runner.invoke(app, ["sessions", "list"])
|
|
87
86
|
|
|
88
87
|
assert result.exit_code == 0, result.output
|
|
89
|
-
assert signed_out.init_kwargs[0]["
|
|
90
|
-
assert signed_out.init_kwargs[0]["api_key"] is None
|
|
88
|
+
assert signed_out.init_kwargs[0]["api_key"] == "stored-access"
|
|
91
89
|
|
|
92
90
|
|
|
93
91
|
def test_expired_session_is_refreshed_and_stored(
|
|
@@ -111,7 +109,7 @@ def test_expired_session_is_refreshed_and_stored(
|
|
|
111
109
|
|
|
112
110
|
assert result.exit_code == 0, result.output
|
|
113
111
|
assert grants == ["refresh"]
|
|
114
|
-
assert signed_out.init_kwargs[0]["
|
|
112
|
+
assert signed_out.init_kwargs[0]["api_key"] == "renewed-access"
|
|
115
113
|
saved = credentials.load(BASE_URL)
|
|
116
114
|
assert saved is not None
|
|
117
115
|
assert saved.access_token == "renewed-access"
|
|
@@ -157,7 +155,7 @@ def test_a_session_expiring_within_the_leeway_is_refreshed(
|
|
|
157
155
|
)
|
|
158
156
|
|
|
159
157
|
assert runner.invoke(app, ["sessions", "list"]).exit_code == 0
|
|
160
|
-
assert signed_out.init_kwargs[0]["
|
|
158
|
+
assert signed_out.init_kwargs[0]["api_key"] == "renewed-access"
|
|
161
159
|
|
|
162
160
|
|
|
163
161
|
def test_rejected_refresh_reports_an_expired_session(
|
|
@@ -206,7 +204,7 @@ def test_no_credential_names_both_ways_to_get_one(
|
|
|
206
204
|
assert "ALBUS_API_KEY" in reported
|
|
207
205
|
|
|
208
206
|
|
|
209
|
-
def
|
|
207
|
+
def test_login_stores_the_session_and_prints_whoami(
|
|
210
208
|
signed_out: FakeAlbus, monkeypatch: pytest.MonkeyPatch, config_dir: Path
|
|
211
209
|
) -> None:
|
|
212
210
|
announced: list[str] = []
|
|
@@ -230,6 +228,8 @@ def test_login_stores_the_session_and_names_the_account(
|
|
|
230
228
|
assert announced == ["cli"]
|
|
231
229
|
assert "https://albus.us.auth0.com/authorize" in result.output
|
|
232
230
|
assert "Signed in as carlo@albus.sh in Albus" in result.output
|
|
231
|
+
assert '"active_organization"' in result.output
|
|
232
|
+
assert '"organizations"' in result.output
|
|
233
233
|
assert "https://albus.sh/api" in result.output
|
|
234
234
|
assert str(config_dir / "credentials.json") in result.output
|
|
235
235
|
saved = credentials.load(BASE_URL)
|
|
@@ -239,7 +239,7 @@ def test_login_stores_the_session_and_names_the_account(
|
|
|
239
239
|
# The account is resolved over the API with the new credential,
|
|
240
240
|
# never by reading the token.
|
|
241
241
|
assert signed_out.calls[-1].name == "whoami"
|
|
242
|
-
assert signed_out.init_kwargs[-1]["
|
|
242
|
+
assert signed_out.init_kwargs[-1]["api_key"] == "fresh-access"
|
|
243
243
|
|
|
244
244
|
|
|
245
245
|
def signs_in(
|
|
@@ -270,7 +270,7 @@ def test_login_names_the_account_it_just_signed_in_as(
|
|
|
270
270
|
|
|
271
271
|
assert result.exit_code == 0, result.output
|
|
272
272
|
assert "as carlo@albus.sh" in result.output
|
|
273
|
-
assert albus.init_kwargs[-1]["
|
|
273
|
+
assert albus.init_kwargs[-1]["api_key"] == "fresh-access"
|
|
274
274
|
|
|
275
275
|
|
|
276
276
|
def test_login_says_an_api_key_shadows_the_new_session(
|
|
@@ -441,7 +441,6 @@ def test_whoami_names_the_caller_the_next_command_sends(
|
|
|
441
441
|
|
|
442
442
|
assert runner.invoke(app, ["whoami"]).exit_code == 0
|
|
443
443
|
assert albus.init_kwargs[0]["api_key"] == "env-key"
|
|
444
|
-
assert albus.init_kwargs[0]["access_token"] is None
|
|
445
444
|
|
|
446
445
|
|
|
447
446
|
def test_whoami_works_with_only_an_api_key(
|
|
@@ -72,8 +72,7 @@ def test_checkout_uses_the_session_over_an_api_key(
|
|
|
72
72
|
"success_url": "https://example.com/ok",
|
|
73
73
|
"cancel_url": "https://example.com/back",
|
|
74
74
|
}
|
|
75
|
-
assert albus.init_kwargs[0]["
|
|
76
|
-
assert albus.init_kwargs[0]["api_key"] is None
|
|
75
|
+
assert albus.init_kwargs[0]["api_key"] == "stored-access"
|
|
77
76
|
assert json.loads(result.stdout)["url"].startswith("https://")
|
|
78
77
|
|
|
79
78
|
|
|
@@ -6,7 +6,8 @@ import pytest
|
|
|
6
6
|
from albus_sdk import errors, models
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
|
-
from albus_cli import client
|
|
9
|
+
from albus_cli import client, credentials
|
|
10
|
+
from albus_cli.credentials import Credential
|
|
10
11
|
from albus_cli.main import app, main
|
|
11
12
|
from tests.conftest import FakeAlbus
|
|
12
13
|
|
|
@@ -234,6 +235,30 @@ def test_without_an_org_the_server_picks_the_organization(
|
|
|
234
235
|
assert sent_organization(albus) is None
|
|
235
236
|
|
|
236
237
|
|
|
238
|
+
def test_saved_org_selects_the_organization_for_a_browser_session(
|
|
239
|
+
albus: FakeAlbus, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
240
|
+
) -> None:
|
|
241
|
+
monkeypatch.setenv(credentials.CONFIG_DIR_ENV, str(tmp_path / "config"))
|
|
242
|
+
monkeypatch.delenv(credentials.XDG_CONFIG_HOME_ENV, raising=False)
|
|
243
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
244
|
+
monkeypatch.delenv(client.API_KEY_ENV, raising=False)
|
|
245
|
+
credentials.save(
|
|
246
|
+
"https://api.albus.sh",
|
|
247
|
+
Credential("access", "refresh", 1_800_000_000),
|
|
248
|
+
)
|
|
249
|
+
stored = credentials.session("https://api.albus.sh")
|
|
250
|
+
assert stored is not None
|
|
251
|
+
credentials.set_organization("https://api.albus.sh", "o2", stored)
|
|
252
|
+
|
|
253
|
+
result = runner.invoke(
|
|
254
|
+
app,
|
|
255
|
+
["--base-url", "https://api.albus.sh", "sessions", "list"],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
assert result.exit_code == 0, result.output
|
|
259
|
+
assert sent_organization(albus) == "o2"
|
|
260
|
+
|
|
261
|
+
|
|
237
262
|
def test_secret_value_read_from_stdin(albus: FakeAlbus) -> None:
|
|
238
263
|
result = runner.invoke(
|
|
239
264
|
app, ["secrets", "create", "gemini-key"], input="s3cret\n"
|
|
@@ -31,6 +31,13 @@ def credential(access_token: str = "access") -> Credential:
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def stored() -> credentials.Session:
|
|
35
|
+
"""The session as a command reads it before acting on it."""
|
|
36
|
+
session = credentials.session(BASE_URL)
|
|
37
|
+
assert session is not None
|
|
38
|
+
return session
|
|
39
|
+
|
|
40
|
+
|
|
34
41
|
def test_saved_credential_round_trips() -> None:
|
|
35
42
|
credentials.save(BASE_URL, credential())
|
|
36
43
|
|
|
@@ -101,7 +108,9 @@ def test_a_trailing_slash_is_the_same_entry() -> None:
|
|
|
101
108
|
credentials.save(f"{BASE_URL}/", credential())
|
|
102
109
|
|
|
103
110
|
assert credentials.load(BASE_URL) == credential()
|
|
104
|
-
assert entries() == {
|
|
111
|
+
assert entries() == {
|
|
112
|
+
BASE_URL: {**entry("access"), "session_id": stored().identity}
|
|
113
|
+
}
|
|
105
114
|
|
|
106
115
|
|
|
107
116
|
def test_two_base_urls_do_not_collide() -> None:
|
|
@@ -126,6 +135,114 @@ def test_delete_keeps_the_other_entries() -> None:
|
|
|
126
135
|
assert credentials.load(other) is not None
|
|
127
136
|
|
|
128
137
|
|
|
138
|
+
def test_saved_organization_round_trips_with_a_credential() -> None:
|
|
139
|
+
credentials.save(BASE_URL, credential())
|
|
140
|
+
|
|
141
|
+
credentials.set_organization(BASE_URL, "42", stored())
|
|
142
|
+
|
|
143
|
+
assert credentials.organization(BASE_URL) == "42"
|
|
144
|
+
assert credentials.load(BASE_URL) == credential()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_saving_a_fresh_login_clears_the_saved_organization() -> None:
|
|
148
|
+
credentials.save(BASE_URL, credential())
|
|
149
|
+
credentials.set_organization(BASE_URL, "42", stored())
|
|
150
|
+
|
|
151
|
+
credentials.save(BASE_URL, credential("new-login"))
|
|
152
|
+
|
|
153
|
+
assert credentials.organization(BASE_URL) is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_session_reads_credential_and_organization_together() -> None:
|
|
157
|
+
credentials.save(BASE_URL, credential())
|
|
158
|
+
credentials.set_organization(BASE_URL, "42", stored())
|
|
159
|
+
|
|
160
|
+
assert credentials.session(BASE_URL) == credentials.Session(
|
|
161
|
+
credential(), "42", stored().identity
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_legacy_session_can_select_an_organization(tmp_path: Path) -> None:
|
|
166
|
+
legacy = tmp_path / "home/.albus/credentials.json"
|
|
167
|
+
other = "https://other.example/api"
|
|
168
|
+
write_document(
|
|
169
|
+
legacy,
|
|
170
|
+
{
|
|
171
|
+
"version": 1,
|
|
172
|
+
"credentials": {BASE_URL: entry("legacy"), other: entry("kept")},
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
credentials.set_organization(BASE_URL, "42", stored())
|
|
177
|
+
|
|
178
|
+
assert credentials.organization(BASE_URL) == "42"
|
|
179
|
+
assert credentials.load(BASE_URL) == credential("legacy")
|
|
180
|
+
assert credentials.load(other) == credential("kept")
|
|
181
|
+
assert json.loads(legacy.read_text())["credentials"][BASE_URL] == entry(
|
|
182
|
+
"legacy"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_set_organization_refuses_a_session_it_did_not_validate() -> None:
|
|
187
|
+
"""The entry a concurrent `login` wrote is another account's; a
|
|
188
|
+
`logout` leaves none. Neither takes the organization."""
|
|
189
|
+
credentials.save(BASE_URL, credential())
|
|
190
|
+
validated = stored()
|
|
191
|
+
|
|
192
|
+
credentials.save(BASE_URL, credential())
|
|
193
|
+
|
|
194
|
+
with pytest.raises(credentials.SessionReplaced):
|
|
195
|
+
credentials.set_organization(BASE_URL, "42", validated)
|
|
196
|
+
|
|
197
|
+
credentials.delete(BASE_URL)
|
|
198
|
+
|
|
199
|
+
with pytest.raises(credentials.SessionReplaced):
|
|
200
|
+
credentials.set_organization(BASE_URL, "42", validated)
|
|
201
|
+
|
|
202
|
+
assert credentials.organization(BASE_URL) is None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_set_organization_accepts_a_session_renewed_meanwhile() -> None:
|
|
206
|
+
"""Renewal rotates every token but is the same sign-in, and must
|
|
207
|
+
not read as a replacement."""
|
|
208
|
+
credentials.save(BASE_URL, credential())
|
|
209
|
+
validated = stored()
|
|
210
|
+
|
|
211
|
+
credentials.renew(BASE_URL, lambda current: credential("renewed"))
|
|
212
|
+
credentials.set_organization(BASE_URL, "42", validated)
|
|
213
|
+
|
|
214
|
+
assert credentials.session(BASE_URL) == credentials.Session(
|
|
215
|
+
credential("renewed"), "42", validated.identity
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_login_over_an_unnamed_session_is_a_replacement(
|
|
220
|
+
tmp_path: Path,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""An entry from before sign-ins were named has no identity; the
|
|
223
|
+
login that replaces it has one, so it is told apart all the same."""
|
|
224
|
+
write_document(
|
|
225
|
+
tmp_path / "home/.albus/credentials.json",
|
|
226
|
+
{"version": 1, "credentials": {BASE_URL: entry("legacy")}},
|
|
227
|
+
)
|
|
228
|
+
validated = stored()
|
|
229
|
+
assert validated.identity is None
|
|
230
|
+
|
|
231
|
+
credentials.save(BASE_URL, credential())
|
|
232
|
+
|
|
233
|
+
with pytest.raises(credentials.SessionReplaced):
|
|
234
|
+
credentials.set_organization(BASE_URL, "42", validated)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_refresh_keeps_the_saved_organization() -> None:
|
|
238
|
+
credentials.save(BASE_URL, credential())
|
|
239
|
+
credentials.set_organization(BASE_URL, "42", stored())
|
|
240
|
+
|
|
241
|
+
credentials.renew(BASE_URL, lambda current: credential("renewed"))
|
|
242
|
+
|
|
243
|
+
assert credentials.organization(BASE_URL) == "42"
|
|
244
|
+
|
|
245
|
+
|
|
129
246
|
def test_delete_without_a_file_writes_nothing() -> None:
|
|
130
247
|
"""Signing out must not be what creates the credentials file."""
|
|
131
248
|
credentials.delete(BASE_URL)
|
|
@@ -76,8 +76,7 @@ def test_the_session_is_used_over_an_api_key(
|
|
|
76
76
|
result = runner.invoke(app, ["invites", "create", "new@example.com"])
|
|
77
77
|
|
|
78
78
|
assert result.exit_code == 0, result.output
|
|
79
|
-
assert signed_in.init_kwargs[0]["
|
|
80
|
-
assert signed_in.init_kwargs[0]["api_key"] is None
|
|
79
|
+
assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
|
|
81
80
|
|
|
82
81
|
|
|
83
82
|
def test_list(signed_in: FakeAlbus) -> None:
|
|
@@ -86,7 +85,7 @@ def test_list(signed_in: FakeAlbus) -> None:
|
|
|
86
85
|
assert result.exit_code == 0, result.output
|
|
87
86
|
assert signed_in.calls[0].name == "list_invites"
|
|
88
87
|
assert json.loads(result.stdout)["invites"][0]["id"] == "i1"
|
|
89
|
-
assert signed_in.init_kwargs[0]["api_key"]
|
|
88
|
+
assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
|
|
90
89
|
|
|
91
90
|
|
|
92
91
|
def test_revoke_prints_nothing(signed_in: FakeAlbus) -> None:
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`albus organization` maps one command to each `/organization` operation
|
|
2
|
+
and accepts either credential, since the API does."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from albus_cli import client, credentials
|
|
11
|
+
from albus_cli.credentials import Credential
|
|
12
|
+
from albus_cli.main import app
|
|
13
|
+
from tests.conftest import FakeAlbus
|
|
14
|
+
from tests.test_cli import sent_organization
|
|
15
|
+
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
BASE_URL = "https://albus.sh/api"
|
|
19
|
+
SESSION = Credential("access", "refresh", 1_800_000_000)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
24
|
+
"""Point the store at tmp_path so the real ~/.config is untouched."""
|
|
25
|
+
directory = tmp_path / "config"
|
|
26
|
+
monkeypatch.setenv(credentials.CONFIG_DIR_ENV, str(directory))
|
|
27
|
+
monkeypatch.delenv(credentials.XDG_CONFIG_HOME_ENV, raising=False)
|
|
28
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
29
|
+
return directory
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def signed_in(albus: FakeAlbus, monkeypatch: pytest.MonkeyPatch) -> FakeAlbus:
|
|
34
|
+
"""The SDK fake behind a stored browser session, with no API key:
|
|
35
|
+
`use` and `default` act on the session alone."""
|
|
36
|
+
monkeypatch.delenv(client.API_KEY_ENV, raising=False)
|
|
37
|
+
credentials.save(BASE_URL, SESSION)
|
|
38
|
+
return albus
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def stored() -> credentials.Session:
|
|
42
|
+
session = credentials.session(BASE_URL)
|
|
43
|
+
assert session is not None
|
|
44
|
+
return session
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_get(albus: FakeAlbus) -> None:
|
|
48
|
+
result = runner.invoke(app, ["organization", "get"])
|
|
49
|
+
|
|
50
|
+
assert result.exit_code == 0, result.output
|
|
51
|
+
assert albus.calls[0].name == "get_organization"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_use_persists_a_membership(signed_in: FakeAlbus) -> None:
|
|
55
|
+
result = runner.invoke(app, ["organization", "use", "o2"])
|
|
56
|
+
|
|
57
|
+
assert result.exit_code == 0, result.output
|
|
58
|
+
assert signed_in.calls[0].name == "whoami"
|
|
59
|
+
assert sent_organization(signed_in) == "o2"
|
|
60
|
+
assert credentials.organization(BASE_URL) == "o2"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_use_needs_a_browser_session(albus: FakeAlbus) -> None:
|
|
64
|
+
result = runner.invoke(app, ["organization", "use", "o2"])
|
|
65
|
+
|
|
66
|
+
assert result.exit_code != 0
|
|
67
|
+
assert albus.calls == []
|
|
68
|
+
assert credentials.organization(BASE_URL) is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_use_rejects_an_empty_id(signed_in: FakeAlbus) -> None:
|
|
72
|
+
credentials.set_organization(BASE_URL, "o2", stored())
|
|
73
|
+
|
|
74
|
+
result = runner.invoke(app, ["organization", "use", ""])
|
|
75
|
+
|
|
76
|
+
assert result.exit_code != 0
|
|
77
|
+
assert signed_in.calls == []
|
|
78
|
+
assert credentials.organization(BASE_URL) == "o2"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_use_refuses_to_save_onto_a_session_that_replaced_the_one_it_checked(
|
|
82
|
+
signed_in: FakeAlbus, monkeypatch: pytest.MonkeyPatch
|
|
83
|
+
) -> None:
|
|
84
|
+
"""A `login` landing between `/whoami` and the write is another
|
|
85
|
+
account, whose session must not inherit this one's organization."""
|
|
86
|
+
whoami = signed_in.auth.whoami
|
|
87
|
+
|
|
88
|
+
def logged_in_meanwhile(**kwargs: object) -> object:
|
|
89
|
+
credentials.save(BASE_URL, Credential("other", None, 1_800_000_000))
|
|
90
|
+
return whoami(**kwargs)
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr(signed_in.auth, "whoami", logged_in_meanwhile)
|
|
93
|
+
|
|
94
|
+
result = runner.invoke(app, ["organization", "use", "o2"])
|
|
95
|
+
|
|
96
|
+
assert isinstance(result.exception, credentials.SessionReplaced)
|
|
97
|
+
assert credentials.organization(BASE_URL) is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_default_clears_the_saved_membership(signed_in: FakeAlbus) -> None:
|
|
101
|
+
credentials.set_organization(BASE_URL, "o2", stored())
|
|
102
|
+
|
|
103
|
+
result = runner.invoke(app, ["organization", "default"])
|
|
104
|
+
|
|
105
|
+
assert result.exit_code == 0, result.output
|
|
106
|
+
assert sent_organization(signed_in) is None
|
|
107
|
+
assert credentials.organization(BASE_URL) is None
|
|
108
|
+
active = json.loads(result.stdout)["user"]["active_organization"]
|
|
109
|
+
assert active["name"] == "Albus"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_default_keeps_the_saved_membership_when_the_request_fails(
|
|
113
|
+
signed_in: FakeAlbus, monkeypatch: pytest.MonkeyPatch
|
|
114
|
+
) -> None:
|
|
115
|
+
credentials.set_organization(BASE_URL, "o2", stored())
|
|
116
|
+
|
|
117
|
+
def refused(**kwargs: object) -> None:
|
|
118
|
+
raise ConnectionError("refused")
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(signed_in.auth, "whoami", refused)
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(app, ["organization", "default"])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code != 0
|
|
125
|
+
assert credentials.organization(BASE_URL) == "o2"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_rename(albus: FakeAlbus) -> None:
|
|
129
|
+
result = runner.invoke(app, ["organization", "rename", "Albus Labs"])
|
|
130
|
+
|
|
131
|
+
assert result.exit_code == 0, result.output
|
|
132
|
+
assert albus.calls[0].name == "update_organization"
|
|
133
|
+
assert albus.calls[0].kwargs == {"name": "Albus Labs"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_members(albus: FakeAlbus) -> None:
|
|
137
|
+
result = runner.invoke(app, ["organization", "members"])
|
|
138
|
+
|
|
139
|
+
assert result.exit_code == 0, result.output
|
|
140
|
+
assert albus.calls[0].name == "list_organization_members"
|
|
141
|
+
assert json.loads(result.stdout)["members"][0]["user_id"] == "u1"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_set_role(albus: FakeAlbus) -> None:
|
|
145
|
+
result = runner.invoke(app, ["organization", "set-role", "u1", "member"])
|
|
146
|
+
|
|
147
|
+
assert result.exit_code == 0, result.output
|
|
148
|
+
assert albus.calls[0].name == "set_organization_member_role"
|
|
149
|
+
assert albus.calls[0].kwargs == {"user_id": "u1", "role": "member"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_set_role_rejects_an_unknown_role(albus: FakeAlbus) -> None:
|
|
153
|
+
result = runner.invoke(app, ["organization", "set-role", "u1", "owner"])
|
|
154
|
+
|
|
155
|
+
assert result.exit_code != 0
|
|
156
|
+
assert albus.calls == []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_remove_member_prints_nothing(albus: FakeAlbus) -> None:
|
|
160
|
+
result = runner.invoke(app, ["organization", "remove-member", "u1"])
|
|
161
|
+
|
|
162
|
+
assert result.exit_code == 0, result.output
|
|
163
|
+
assert albus.calls[0].name == "remove_organization_member"
|
|
164
|
+
assert albus.calls[0].kwargs == {"user_id": "u1"}
|
|
165
|
+
assert result.stdout == ""
|
|
@@ -85,8 +85,7 @@ def test_the_session_is_used_over_an_api_key(
|
|
|
85
85
|
monkeypatch.setenv(client.API_KEY_ENV, "env-key")
|
|
86
86
|
|
|
87
87
|
assert runner.invoke(app, ["tokens", "list"]).exit_code == 0
|
|
88
|
-
assert signed_in.init_kwargs[0]["
|
|
89
|
-
assert signed_in.init_kwargs[0]["api_key"] is None
|
|
88
|
+
assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
|
|
90
89
|
|
|
91
90
|
|
|
92
91
|
def test_only_an_api_key_asks_for_a_sign_in(
|
|
@@ -8,7 +8,7 @@ resolution-markers = [
|
|
|
8
8
|
|
|
9
9
|
[[package]]
|
|
10
10
|
name = "albus-cli"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.8.0"
|
|
12
12
|
source = { editable = "." }
|
|
13
13
|
dependencies = [
|
|
14
14
|
{ name = "albus-sdk" },
|
|
@@ -24,7 +24,7 @@ dev = [
|
|
|
24
24
|
|
|
25
25
|
[package.metadata]
|
|
26
26
|
requires-dist = [
|
|
27
|
-
{ name = "albus-sdk", specifier = "==0.
|
|
27
|
+
{ name = "albus-sdk", specifier = "==0.16.0" },
|
|
28
28
|
{ name = "typer", specifier = ">=0.26.8,<0.28" },
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -37,16 +37,16 @@ dev = [
|
|
|
37
37
|
|
|
38
38
|
[[package]]
|
|
39
39
|
name = "albus-sdk"
|
|
40
|
-
version = "0.
|
|
40
|
+
version = "0.16.0"
|
|
41
41
|
source = { registry = "https://pypi.org/simple" }
|
|
42
42
|
dependencies = [
|
|
43
43
|
{ name = "httpcore" },
|
|
44
44
|
{ name = "httpx" },
|
|
45
45
|
{ name = "pydantic" },
|
|
46
46
|
]
|
|
47
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
47
|
+
sdist = { url = "https://files.pythonhosted.org/packages/99/b1/17e68fe0ee1af1205b3133b41d68b40c60d1895fe561de4085617fd6ad56/albus_sdk-0.16.0.tar.gz", hash = "sha256:61fd36f55cbd0fa59f6dd038caeb1fe7364aa0e590c7341517ae116595b5b548", size = 89838, upload-time = "2026-09-08T02:46:29.364Z" }
|
|
48
48
|
wheels = [
|
|
49
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
49
|
+
{ url = "https://files.pythonhosted.org/packages/26/0e/22e4d6437a71c3faa7caeca4bb5d7af5a491b043c46e715e30aab905ca6a/albus_sdk-0.16.0-py3-none-any.whl", hash = "sha256:8409e81c1745043ca41fc5783ec89f8c206987bb9f0e62f7bfacae7e4e8d3633", size = 146438, upload-time = "2026-09-08T02:46:28.184Z" },
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
[[package]]
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"""`albus organization` maps one command to each `/organization` operation
|
|
2
|
-
and accepts either credential, since the API does."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
|
|
6
|
-
from typer.testing import CliRunner
|
|
7
|
-
|
|
8
|
-
from albus_cli.main import app
|
|
9
|
-
from tests.conftest import FakeAlbus
|
|
10
|
-
|
|
11
|
-
runner = CliRunner()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_get(albus: FakeAlbus) -> None:
|
|
15
|
-
result = runner.invoke(app, ["organization", "get"])
|
|
16
|
-
|
|
17
|
-
assert result.exit_code == 0, result.output
|
|
18
|
-
assert albus.calls[0].name == "get_organization"
|
|
19
|
-
assert json.loads(result.stdout)["name"] == "Albus"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_rename(albus: FakeAlbus) -> None:
|
|
23
|
-
result = runner.invoke(app, ["organization", "rename", "Albus Labs"])
|
|
24
|
-
|
|
25
|
-
assert result.exit_code == 0, result.output
|
|
26
|
-
assert albus.calls[0].name == "update_organization"
|
|
27
|
-
assert albus.calls[0].kwargs == {"name": "Albus Labs"}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_members(albus: FakeAlbus) -> None:
|
|
31
|
-
result = runner.invoke(app, ["organization", "members"])
|
|
32
|
-
|
|
33
|
-
assert result.exit_code == 0, result.output
|
|
34
|
-
assert albus.calls[0].name == "list_organization_members"
|
|
35
|
-
assert json.loads(result.stdout)["members"][0]["user_id"] == "u1"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_set_role(albus: FakeAlbus) -> None:
|
|
39
|
-
result = runner.invoke(app, ["organization", "set-role", "u1", "member"])
|
|
40
|
-
|
|
41
|
-
assert result.exit_code == 0, result.output
|
|
42
|
-
assert albus.calls[0].name == "set_organization_member_role"
|
|
43
|
-
assert albus.calls[0].kwargs == {"user_id": "u1", "role": "member"}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_set_role_rejects_an_unknown_role(albus: FakeAlbus) -> None:
|
|
47
|
-
result = runner.invoke(app, ["organization", "set-role", "u1", "owner"])
|
|
48
|
-
|
|
49
|
-
assert result.exit_code != 0
|
|
50
|
-
assert albus.calls == []
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_remove_member_prints_nothing(albus: FakeAlbus) -> None:
|
|
54
|
-
result = runner.invoke(app, ["organization", "remove-member", "u1"])
|
|
55
|
-
|
|
56
|
-
assert result.exit_code == 0, result.output
|
|
57
|
-
assert albus.calls[0].name == "remove_organization_member"
|
|
58
|
-
assert albus.calls[0].kwargs == {"user_id": "u1"}
|
|
59
|
-
assert result.stdout == ""
|
|
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
|
|
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
|
|
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
|