quaestor-cli 0.3.0__tar.gz → 0.3.1__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.
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/PKG-INFO +17 -6
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/README.md +16 -5
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/pyproject.toml +1 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/doctor.py +34 -9
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/config.py +11 -11
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/main.py +2 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/runtime.py +2 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_auth.py +72 -2
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_timer_review_doctor.py +68 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/uv.lock +1 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/.gitignore +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/__init__.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/api.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/client.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/__init__.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/auth.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/events.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/log.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quest.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quest_format.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quests.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/review.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/show.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/streaks.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/timer.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/errors.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/models.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/output.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/parse.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/quest_api.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/quest_models.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/resolve.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/window.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/conftest.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/factories.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_errors.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_events.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_log.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_quest_emoji.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_quests.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_review_fixes.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_show.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_parse.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_quest_parse_and_doctor.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_resolve.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_window.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quaestor-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Command-line client for the Quaestor API — built for humans and agents.
|
|
5
5
|
Project-URL: Homepage, https://quaestor.app
|
|
6
6
|
Project-URL: Repository, https://github.com/neuromaxer/quaestor-lite
|
|
@@ -39,10 +39,9 @@ uv tool install quaestor-cli
|
|
|
39
39
|
## Setup
|
|
40
40
|
|
|
41
41
|
Create an API key in the Quaestor web app under **Profile → Developer Access**.
|
|
42
|
-
|
|
42
|
+
The CLI uses the hosted production API automatically. On macOS, copy the key and run:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
|
|
46
45
|
pbpaste | qst auth login
|
|
47
46
|
qst doctor
|
|
48
47
|
```
|
|
@@ -54,9 +53,9 @@ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
|
|
|
54
53
|
CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
|
|
55
54
|
precedence over the file, but it is not needed for this setup.
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
56
|
+
Logging in again replaces the key while preserving an existing server override.
|
|
57
|
+
`qst doctor` reports authentication as unknown when the server cannot verify it;
|
|
58
|
+
only an explicit authentication refusal means the key was rejected.
|
|
60
59
|
|
|
61
60
|
## The evening diary workflow
|
|
62
61
|
|
|
@@ -219,3 +218,15 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
|
|
|
219
218
|
|
|
220
219
|
Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
|
|
221
220
|
Quaestor backend, so it installs in seconds and tolerates version skew against the server.
|
|
221
|
+
|
|
222
|
+
## Developer configuration
|
|
223
|
+
|
|
224
|
+
Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
|
|
225
|
+
The development flag is hidden from normal help and never persists a local URL.
|
|
226
|
+
|
|
227
|
+
Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
|
|
228
|
+
`base_url` in the credential store. Resolution order is `--dev`, environment, stored
|
|
229
|
+
URL, then the hosted production default. Ordinary hosted setup needs no override.
|
|
230
|
+
|
|
231
|
+
`qst` refuses to send a key over plain HTTP except to loopback. Set
|
|
232
|
+
`QUAESTOR_ALLOW_INSECURE=1` only for deliberate plaintext development deployments.
|
|
@@ -17,10 +17,9 @@ uv tool install quaestor-cli
|
|
|
17
17
|
## Setup
|
|
18
18
|
|
|
19
19
|
Create an API key in the Quaestor web app under **Profile → Developer Access**.
|
|
20
|
-
|
|
20
|
+
The CLI uses the hosted production API automatically. On macOS, copy the key and run:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
|
|
24
23
|
pbpaste | qst auth login
|
|
25
24
|
qst doctor
|
|
26
25
|
```
|
|
@@ -32,9 +31,9 @@ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
|
|
|
32
31
|
CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
|
|
33
32
|
precedence over the file, but it is not needed for this setup.
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
34
|
+
Logging in again replaces the key while preserving an existing server override.
|
|
35
|
+
`qst doctor` reports authentication as unknown when the server cannot verify it;
|
|
36
|
+
only an explicit authentication refusal means the key was rejected.
|
|
38
37
|
|
|
39
38
|
## The evening diary workflow
|
|
40
39
|
|
|
@@ -197,3 +196,15 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
|
|
|
197
196
|
|
|
198
197
|
Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
|
|
199
198
|
Quaestor backend, so it installs in seconds and tolerates version skew against the server.
|
|
199
|
+
|
|
200
|
+
## Developer configuration
|
|
201
|
+
|
|
202
|
+
Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
|
|
203
|
+
The development flag is hidden from normal help and never persists a local URL.
|
|
204
|
+
|
|
205
|
+
Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
|
|
206
|
+
`base_url` in the credential store. Resolution order is `--dev`, environment, stored
|
|
207
|
+
URL, then the hosted production default. Ordinary hosted setup needs no override.
|
|
208
|
+
|
|
209
|
+
`qst` refuses to send a key over plain HTTP except to loopback. Set
|
|
210
|
+
`QUAESTOR_ALLOW_INSECURE=1` only for deliberate plaintext development deployments.
|
|
@@ -10,7 +10,7 @@ from quaestor_cli import __version__, api
|
|
|
10
10
|
from quaestor_cli.client import QuaestorClient
|
|
11
11
|
from quaestor_cli.config import ENV_API_KEY, credentials_path
|
|
12
12
|
from quaestor_cli.errors import CliError, ExitCode
|
|
13
|
-
from quaestor_cli.output import emit, emit_json, format_scopes
|
|
13
|
+
from quaestor_cli.output import emit, emit_json, format_scopes, warn
|
|
14
14
|
from quaestor_cli.runtime import CliState, JsonFlag, cli_state, execute
|
|
15
15
|
|
|
16
16
|
UNKNOWN = "unknown"
|
|
@@ -45,7 +45,7 @@ class Diagnosis:
|
|
|
45
45
|
credential_source: str
|
|
46
46
|
key_label: str
|
|
47
47
|
reachable: bool
|
|
48
|
-
authenticated: bool
|
|
48
|
+
authenticated: bool | None
|
|
49
49
|
scopes: list[str] | None
|
|
50
50
|
"""None when the server has no /auth/whoami, i.e. scopes are unknowable."""
|
|
51
51
|
user: str | None = None
|
|
@@ -58,6 +58,8 @@ class Diagnosis:
|
|
|
58
58
|
"""Worst problem found decides the exit code."""
|
|
59
59
|
if not self.reachable:
|
|
60
60
|
return ExitCode.SERVER
|
|
61
|
+
if self.authenticated is None:
|
|
62
|
+
return ExitCode.SERVER
|
|
61
63
|
if not self.authenticated:
|
|
62
64
|
return ExitCode.AUTH
|
|
63
65
|
return ExitCode.OK
|
|
@@ -77,7 +79,12 @@ def _run(state: CliState) -> None:
|
|
|
77
79
|
else:
|
|
78
80
|
emit(*_lines(diagnosis))
|
|
79
81
|
if diagnosis.exit_code is not ExitCode.OK:
|
|
80
|
-
|
|
82
|
+
detail = diagnosis.detail or "Health check failed."
|
|
83
|
+
if state.json_output:
|
|
84
|
+
# The diagnosis already includes the failure; keep stdout one JSON document.
|
|
85
|
+
warn(detail)
|
|
86
|
+
raise typer.Exit(code=int(diagnosis.exit_code))
|
|
87
|
+
raise CliError(detail, diagnosis.exit_code)
|
|
81
88
|
|
|
82
89
|
|
|
83
90
|
def diagnose(state: CliState) -> Diagnosis:
|
|
@@ -86,13 +93,14 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
86
93
|
anonymous = QuaestorClient(base_url=state.config.base_url)
|
|
87
94
|
health = anonymous.probe(api.HEALTHZ_PATH)
|
|
88
95
|
|
|
89
|
-
authenticated =
|
|
96
|
+
authenticated: bool | None = None
|
|
90
97
|
scopes: list[str] | None = None
|
|
91
98
|
user: str | None = None
|
|
92
99
|
detail: str | None = None
|
|
93
100
|
if not health.is_success:
|
|
94
101
|
detail = health.detail
|
|
95
102
|
elif credential is None:
|
|
103
|
+
authenticated = False
|
|
96
104
|
detail = f"No API key: set {ENV_API_KEY} or run 'qst auth login'."
|
|
97
105
|
else:
|
|
98
106
|
auth = _check_credential(state)
|
|
@@ -106,7 +114,7 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
106
114
|
base_url=state.config.base_url,
|
|
107
115
|
credential_source=credential.source if credential else "none",
|
|
108
116
|
key_label=credential.label if credential else "-",
|
|
109
|
-
reachable=health.
|
|
117
|
+
reachable=health.reachable,
|
|
110
118
|
authenticated=authenticated,
|
|
111
119
|
scopes=scopes,
|
|
112
120
|
user=user,
|
|
@@ -119,7 +127,7 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
119
127
|
class _CredentialCheck:
|
|
120
128
|
"""What the identity lookup could establish about the credential."""
|
|
121
129
|
|
|
122
|
-
authenticated: bool
|
|
130
|
+
authenticated: bool | None
|
|
123
131
|
scopes: list[str] | None
|
|
124
132
|
user: str | None
|
|
125
133
|
detail: str | None
|
|
@@ -138,12 +146,17 @@ def _check_credential(state: CliState) -> _CredentialCheck:
|
|
|
138
146
|
detail=None,
|
|
139
147
|
)
|
|
140
148
|
if not lookup.is_unsupported:
|
|
141
|
-
return _CredentialCheck(
|
|
149
|
+
return _CredentialCheck(
|
|
150
|
+
authenticated=False if lookup.probe.rejected_credential else None,
|
|
151
|
+
scopes=None,
|
|
152
|
+
user=None,
|
|
153
|
+
detail=lookup.probe.detail,
|
|
154
|
+
)
|
|
142
155
|
|
|
143
156
|
# Server predates /auth/whoami: doctor must still say whether the key works.
|
|
144
157
|
fallback = client.probe(api.STREAKS_PATH, {"limit": 1})
|
|
145
158
|
return _CredentialCheck(
|
|
146
|
-
authenticated=fallback.is_success,
|
|
159
|
+
authenticated=True if fallback.is_success else False if fallback.rejected_credential else None,
|
|
147
160
|
scopes=None,
|
|
148
161
|
user=None,
|
|
149
162
|
detail=None if fallback.is_success else fallback.detail,
|
|
@@ -154,19 +167,31 @@ def _lines(diagnosis: Diagnosis) -> tuple[str, ...]:
|
|
|
154
167
|
"""Render the diagnosis as aligned human-readable lines."""
|
|
155
168
|
if diagnosis.scopes is not None:
|
|
156
169
|
scopes = format_scopes(diagnosis.scopes)
|
|
170
|
+
elif not diagnosis.reachable:
|
|
171
|
+
scopes = f"{UNKNOWN} (host unreachable)"
|
|
172
|
+
elif diagnosis.authenticated is None:
|
|
173
|
+
scopes = f"{UNKNOWN} (authentication check failed)"
|
|
174
|
+
elif diagnosis.credential_source == "none":
|
|
175
|
+
scopes = f"{UNKNOWN} (no API key)"
|
|
157
176
|
elif not diagnosis.authenticated:
|
|
158
177
|
# The credential was rejected, so scopes were never knowable. Blaming the
|
|
159
178
|
# server's age here points the reader at the wrong problem.
|
|
160
179
|
scopes = f"{UNKNOWN} (credential was rejected)"
|
|
161
180
|
else:
|
|
162
181
|
scopes = f"{UNKNOWN} (this server has no /auth/whoami)"
|
|
182
|
+
if not diagnosis.reachable:
|
|
183
|
+
authenticated = f"{UNKNOWN} (host unreachable)"
|
|
184
|
+
elif diagnosis.authenticated is None:
|
|
185
|
+
authenticated = f"{UNKNOWN} (authentication check failed)"
|
|
186
|
+
else:
|
|
187
|
+
authenticated = "yes" if diagnosis.authenticated else "no"
|
|
163
188
|
fields = (
|
|
164
189
|
("cli version", diagnosis.cli_version),
|
|
165
190
|
("base url", diagnosis.base_url),
|
|
166
191
|
("credential", f"{diagnosis.credential_source} ({diagnosis.key_label})"),
|
|
167
192
|
("store", str(credentials_path())),
|
|
168
193
|
("reachable", "yes" if diagnosis.reachable else "no"),
|
|
169
|
-
("authenticated",
|
|
194
|
+
("authenticated", authenticated),
|
|
170
195
|
("user", diagnosis.user or "-"),
|
|
171
196
|
("scopes", scopes),
|
|
172
197
|
)
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
The environment is the primary mechanism (`QUAESTOR_API_KEY`, `QUAESTOR_URL`);
|
|
4
|
-
`~/.config/quaestor/credentials.json` is the fallback store written by
|
|
5
|
-
`qst auth login`. The environment always wins over the file.
|
|
6
|
-
"""
|
|
1
|
+
"""Resolve credentials and developer URL overrides, defaulting to hosted Quaestor."""
|
|
7
2
|
|
|
8
3
|
from __future__ import annotations
|
|
9
4
|
|
|
@@ -16,7 +11,8 @@ from urllib.parse import urlparse
|
|
|
16
11
|
|
|
17
12
|
from quaestor_cli.errors import auth_error, server_error, usage_error
|
|
18
13
|
|
|
19
|
-
DEFAULT_BASE_URL = "
|
|
14
|
+
DEFAULT_BASE_URL = "https://ql-backend-fniqyq6lfa-ey.a.run.app"
|
|
15
|
+
LOCAL_BASE_URL = "http://localhost:8000"
|
|
20
16
|
ENV_API_KEY = "QUAESTOR_API_KEY"
|
|
21
17
|
ENV_BASE_URL = "QUAESTOR_URL"
|
|
22
18
|
|
|
@@ -79,8 +75,8 @@ def credentials_path() -> Path:
|
|
|
79
75
|
return config_dir() / "credentials.json"
|
|
80
76
|
|
|
81
77
|
|
|
82
|
-
def load_config() -> Config:
|
|
83
|
-
"""Resolve
|
|
78
|
+
def load_config(*, dev: bool = False) -> Config:
|
|
79
|
+
"""Resolve an explicit dev target, then environment, file, and hosted defaults."""
|
|
84
80
|
stored = _read_credentials_file()
|
|
85
81
|
env_key = os.environ.get(ENV_API_KEY, "").strip()
|
|
86
82
|
|
|
@@ -91,7 +87,7 @@ def load_config() -> Config:
|
|
|
91
87
|
credential = Credential(api_key=stored.api_key, source="file")
|
|
92
88
|
|
|
93
89
|
env_url = os.environ.get(ENV_BASE_URL, "").strip()
|
|
94
|
-
base_url = (env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
90
|
+
base_url = (LOCAL_BASE_URL if dev else env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
95
91
|
return Config(base_url=base_url, credential=credential)
|
|
96
92
|
|
|
97
93
|
|
|
@@ -157,7 +153,11 @@ def save_credential(api_key: str) -> Path:
|
|
|
157
153
|
os.chmod(directory, _CREDENTIALS_DIR_MODE)
|
|
158
154
|
|
|
159
155
|
path = credentials_path()
|
|
160
|
-
|
|
156
|
+
stored = _read_credentials_file()
|
|
157
|
+
contents = {"api_key": key}
|
|
158
|
+
if stored.base_url:
|
|
159
|
+
contents["base_url"] = stored.base_url
|
|
160
|
+
payload = json.dumps(contents, indent=2) + "\n"
|
|
161
161
|
# Open with the mode up front so the secret is never briefly world-readable,
|
|
162
162
|
# then chmod as well for the case where the file already existed.
|
|
163
163
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _CREDENTIALS_FILE_MODE)
|
|
@@ -47,12 +47,13 @@ def main(
|
|
|
47
47
|
help="Reuse this key when retrying a write (add, note, log) so it is applied once.",
|
|
48
48
|
),
|
|
49
49
|
version: bool = typer.Option(False, "--version", help="Print the CLI version and exit."),
|
|
50
|
+
dev: bool = typer.Option(False, "--dev", hidden=True, help="Use the local development API."),
|
|
50
51
|
) -> None:
|
|
51
52
|
"""Accept global flags before the subcommand, e.g. `qst --json streaks`."""
|
|
52
53
|
if version:
|
|
53
54
|
typer.echo(__version__)
|
|
54
55
|
raise typer.Exit()
|
|
55
|
-
ctx.obj = RootFlags(json_output=json_output, dry_run=dry_run, idempotency_key=idempotency_key)
|
|
56
|
+
ctx.obj = RootFlags(json_output=json_output, dry_run=dry_run, idempotency_key=idempotency_key, dev=dev)
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
def _with_window_rules(summary: str) -> str:
|
|
@@ -36,6 +36,7 @@ class RootFlags:
|
|
|
36
36
|
json_output: bool = False
|
|
37
37
|
dry_run: bool = False
|
|
38
38
|
idempotency_key: str | None = None
|
|
39
|
+
dev: bool = False
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def _default_idempotency_key() -> str:
|
|
@@ -74,7 +75,7 @@ def cli_state(ctx: typer.Context, json_output: bool = False, dry_run: bool = Fal
|
|
|
74
75
|
return CliState(
|
|
75
76
|
json_output=json_output or root.json_output,
|
|
76
77
|
dry_run=dry_run or root.dry_run,
|
|
77
|
-
config=load_config(),
|
|
78
|
+
config=load_config(dev=root.dev),
|
|
78
79
|
**extra,
|
|
79
80
|
)
|
|
80
81
|
|
|
@@ -10,7 +10,7 @@ import pytest
|
|
|
10
10
|
import respx
|
|
11
11
|
from typer.testing import CliRunner
|
|
12
12
|
|
|
13
|
-
from conftest import FAKE_KEY
|
|
13
|
+
from conftest import BASE_URL, FAKE_KEY
|
|
14
14
|
from factories import (
|
|
15
15
|
API_KEY_ID,
|
|
16
16
|
AUTH_USER_ID,
|
|
@@ -18,7 +18,7 @@ from factories import (
|
|
|
18
18
|
streak_payload,
|
|
19
19
|
whoami_payload,
|
|
20
20
|
)
|
|
21
|
-
from quaestor_cli.config import credentials_path
|
|
21
|
+
from quaestor_cli.config import DEFAULT_BASE_URL, LOCAL_BASE_URL, credentials_path, load_config
|
|
22
22
|
from quaestor_cli.main import app
|
|
23
23
|
|
|
24
24
|
STORED_KEY = "qst_live_fedcba9876543210_stored-secret"
|
|
@@ -44,6 +44,76 @@ def test_login_without_input_exits_one(runner: CliRunner) -> None:
|
|
|
44
44
|
assert "No API key on stdin" in result.stderr
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_login_preserves_stored_server_when_replacing_key(
|
|
48
|
+
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Logging in again keeps the server that subsequent commands use."""
|
|
51
|
+
path = credentials_path()
|
|
52
|
+
path.parent.mkdir(parents=True)
|
|
53
|
+
path.write_text(json.dumps({"api_key": FAKE_KEY, "base_url": BASE_URL}))
|
|
54
|
+
monkeypatch.delenv("QUAESTOR_URL")
|
|
55
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
56
|
+
api_mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
57
|
+
|
|
58
|
+
login = runner.invoke(app, ["auth", "login"], input=f"{STORED_KEY}\n")
|
|
59
|
+
|
|
60
|
+
assert login.exit_code == 0, login.output
|
|
61
|
+
assert json.loads(path.read_text()) == {"api_key": STORED_KEY, "base_url": BASE_URL}
|
|
62
|
+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
63
|
+
whoami = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
64
|
+
assert whoami.exit_code == 0, whoami.output
|
|
65
|
+
assert json.loads(whoami.stdout)["base_url"] == BASE_URL
|
|
66
|
+
assert api_mock.calls.last.request.headers["authorization"] == f"Bearer {STORED_KEY}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_login_uses_hosted_api_without_url_configuration(
|
|
70
|
+
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Fresh setup reaches production after storing only the API key."""
|
|
73
|
+
monkeypatch.delenv("QUAESTOR_URL")
|
|
74
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
75
|
+
assert DEFAULT_BASE_URL.startswith("https://")
|
|
76
|
+
assert DEFAULT_BASE_URL != LOCAL_BASE_URL
|
|
77
|
+
|
|
78
|
+
login = runner.invoke(app, ["auth", "login"], input=f"{STORED_KEY}\n")
|
|
79
|
+
|
|
80
|
+
assert login.exit_code == 0, login.output
|
|
81
|
+
with respx.mock(base_url=f"{DEFAULT_BASE_URL}/api/v1") as mock:
|
|
82
|
+
whoami = mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
83
|
+
result = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
84
|
+
|
|
85
|
+
assert result.exit_code == 0, result.output
|
|
86
|
+
assert json.loads(result.stdout)["base_url"] == DEFAULT_BASE_URL
|
|
87
|
+
assert whoami.calls.last.request.headers["authorization"] == f"Bearer {STORED_KEY}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_dev_flag_overrides_other_servers_without_changing_the_store(
|
|
91
|
+
runner: CliRunner,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Local testing is explicit and does not change the server for later commands."""
|
|
94
|
+
path = credentials_path()
|
|
95
|
+
path.parent.mkdir(parents=True)
|
|
96
|
+
contents = {"api_key": STORED_KEY, "base_url": "https://staging.example"}
|
|
97
|
+
path.write_text(json.dumps(contents))
|
|
98
|
+
|
|
99
|
+
with respx.mock(base_url=f"{LOCAL_BASE_URL}/api/v1") as mock:
|
|
100
|
+
mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
101
|
+
result = runner.invoke(app, ["--dev", "auth", "whoami", "--json"])
|
|
102
|
+
|
|
103
|
+
assert result.exit_code == 0, result.output
|
|
104
|
+
assert json.loads(result.stdout)["base_url"] == LOCAL_BASE_URL
|
|
105
|
+
assert json.loads(path.read_text()) == contents
|
|
106
|
+
assert load_config().base_url == BASE_URL
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_dev_flag_is_not_shown_in_normal_help(runner: CliRunner) -> None:
|
|
110
|
+
"""Development configuration stays out of the normal setup path."""
|
|
111
|
+
result = runner.invoke(app, ["--help"], color=False)
|
|
112
|
+
|
|
113
|
+
assert result.exit_code == 0, result.output
|
|
114
|
+
assert "--dev" not in result.stdout
|
|
115
|
+
|
|
116
|
+
|
|
47
117
|
def test_stored_key_is_used_when_environment_is_empty(
|
|
48
118
|
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
49
119
|
) -> None:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
|
+
import pytest
|
|
8
9
|
import respx
|
|
9
10
|
from typer.testing import CliRunner
|
|
10
11
|
|
|
@@ -277,9 +278,76 @@ def test_doctor_reports_unreachable_backend(runner: CliRunner, api_mock: respx.M
|
|
|
277
278
|
|
|
278
279
|
assert result.exit_code == 4
|
|
279
280
|
assert "reachable: no" in result.stdout
|
|
281
|
+
assert "authenticated: unknown (host unreachable)" in result.stdout
|
|
282
|
+
assert "scopes: unknown (host unreachable)" in result.stdout
|
|
283
|
+
assert "credential was rejected" not in result.stdout
|
|
280
284
|
assert "Cannot reach" in result.stderr
|
|
281
285
|
|
|
282
286
|
|
|
287
|
+
def test_doctor_reports_unknown_authentication_in_json_when_unreachable(
|
|
288
|
+
runner: CliRunner, api_mock: respx.MockRouter
|
|
289
|
+
) -> None:
|
|
290
|
+
"""An unreachable host cannot establish whether the credential works."""
|
|
291
|
+
api_mock.get("/healthz").mock(side_effect=httpx.ConnectError("connection refused"))
|
|
292
|
+
|
|
293
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
294
|
+
|
|
295
|
+
assert result.exit_code == 4, result.output
|
|
296
|
+
diagnosis = json.loads(result.stdout)
|
|
297
|
+
assert diagnosis["reachable"] is False
|
|
298
|
+
assert diagnosis["authenticated"] is None
|
|
299
|
+
assert diagnosis["scopes"] is None
|
|
300
|
+
assert len(api_mock.calls) == 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.parametrize("response", ["connection", "server"])
|
|
304
|
+
def test_doctor_reports_unknown_when_authentication_check_fails(
|
|
305
|
+
runner: CliRunner, api_mock: respx.MockRouter, response: str
|
|
306
|
+
) -> None:
|
|
307
|
+
"""A working health check does not turn a failed identity lookup into rejection."""
|
|
308
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
309
|
+
identity = api_mock.get("/auth/whoami")
|
|
310
|
+
if response == "connection":
|
|
311
|
+
identity.mock(side_effect=httpx.ConnectError("connection refused"))
|
|
312
|
+
else:
|
|
313
|
+
identity.mock(return_value=httpx.Response(503, json={"detail": "Unavailable"}))
|
|
314
|
+
|
|
315
|
+
result = runner.invoke(app, ["doctor"])
|
|
316
|
+
|
|
317
|
+
assert result.exit_code == 4, result.output
|
|
318
|
+
assert "reachable: yes" in result.stdout
|
|
319
|
+
assert "authenticated: unknown (authentication check failed)" in result.stdout
|
|
320
|
+
assert "credential was rejected" not in result.stdout
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_doctor_reports_reachable_but_unhealthy_server(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
324
|
+
"""A server that answers with 503 is reachable, but cannot verify the key."""
|
|
325
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(503, json={"detail": "Unavailable"}))
|
|
326
|
+
|
|
327
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
328
|
+
|
|
329
|
+
assert result.exit_code == 4, result.output
|
|
330
|
+
diagnosis = json.loads(result.stdout)
|
|
331
|
+
assert diagnosis["reachable"] is True
|
|
332
|
+
assert diagnosis["authenticated"] is None
|
|
333
|
+
assert diagnosis["detail"] == "Unavailable"
|
|
334
|
+
assert len(api_mock.calls) == 1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_doctor_does_not_claim_rejection_when_no_key_is_configured(
|
|
338
|
+
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
339
|
+
) -> None:
|
|
340
|
+
"""A missing credential has never been presented for authentication."""
|
|
341
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
342
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
343
|
+
|
|
344
|
+
result = runner.invoke(app, ["doctor"])
|
|
345
|
+
|
|
346
|
+
assert result.exit_code == 2, result.output
|
|
347
|
+
assert "scopes: unknown (no API key)" in result.stdout
|
|
348
|
+
assert "credential was rejected" not in result.stdout
|
|
349
|
+
|
|
350
|
+
|
|
283
351
|
def test_doctor_reports_bad_credential(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
284
352
|
"""Backend is up but the key is rejected; doctor exits with the auth code."""
|
|
285
353
|
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
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
|