Skip to content

Commit 12e033f

Browse files
committed
tests: e2e tests for PAT usage with CLI
1 parent 06bd372 commit 12e033f

4 files changed

Lines changed: 191 additions & 18 deletions

File tree

tests/e2e_integration/cli_commands/conftest.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,15 @@ def factory(CONSTANTS):
110110
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
111111
# tokens can either be stored in device keyring (or in a fallback file if e.g. keyring not available - likely for CI or disabled for a test)
112112
with contextlib.suppress(KeyringError):
113-
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
114-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
113+
keyring.delete_password(
114+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_TOKENS_USERNAME
115+
)
116+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
117+
with contextlib.suppress(KeyringError):
118+
keyring.delete_password(
119+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_PATS_USERNAME
120+
)
121+
cli_settings.PATS_FALLBACK_PATH.unlink(missing_ok=True)
115122

116123
# running any cmd that requires the config file will create it
117124
for project in CONSTANTS["PROJECT_TO_BUCKET_MAP"]:
@@ -139,7 +146,14 @@ def factory(CONSTANTS):
139146
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
140147
# tokens can either be stored in device keyring (or in a fallback file if e.g. keyring not available - likely for CI or disabled for a test)
141148
with contextlib.suppress(KeyringError):
142-
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
143-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
149+
keyring.delete_password(
150+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_TOKENS_USERNAME
151+
)
152+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
153+
with contextlib.suppress(KeyringError):
154+
keyring.delete_password(
155+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_PATS_USERNAME
156+
)
157+
cli_settings.PATS_FALLBACK_PATH.unlink(missing_ok=True)
144158

145159
return factory

tests/e2e_integration/cli_commands/test_auth_cli.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44

55
import shutil
6+
from datetime import datetime, timedelta
67

78
import keyring
89
import pytest
10+
from click.testing import Result
911
from keyring.errors import NoKeyringError
1012
from pydantic import SecretStr
1113
from typer.testing import CliRunner
@@ -39,9 +41,9 @@ def make_tokens_expired(access: bool = False, refresh: bool = False):
3941
Helper function to make either the access token or refresh token expired in the tokens file.
4042
Sets the expiry time to 1970 (unix time stamp)
4143
"""
42-
with open(cli_settings.TOKENS_PATH, "r") as token_file:
44+
with open(cli_settings.TOKENS_FALLBACK_PATH, "r") as token_file:
4345
lines = token_file.readlines()
44-
with open(cli_settings.TOKENS_PATH, "w") as token_file:
46+
with open(cli_settings.TOKENS_FALLBACK_PATH, "w") as token_file:
4547
for line in lines:
4648
if access and line.startswith("access_token_expires_at:"):
4749
token_file.write("access_token_expires_at: 1\n")
@@ -220,12 +222,12 @@ def test_using_revoked_refresh_token_fails(tmp_path, disable_keyring_backend):
220222
log_in_as_user()
221223

222224
shutil.copy(cli_settings.CONFIG_PATH, tmp_path / "config_backup.yaml")
223-
shutil.copy(cli_settings.TOKENS_PATH, tmp_path / "tokens_backup.yaml")
225+
shutil.copy(cli_settings.TOKENS_FALLBACK_PATH, tmp_path / "tokens_backup.yaml")
224226

225227
result = runner.invoke(app=app, args=logout_command)
226228
assert result.exit_code == 0
227229

228-
shutil.copy(tmp_path / "tokens_backup.yaml", cli_settings.TOKENS_PATH)
230+
shutil.copy(tmp_path / "tokens_backup.yaml", cli_settings.TOKENS_FALLBACK_PATH)
229231
shutil.copy(tmp_path / "config_backup.yaml", cli_settings.CONFIG_PATH)
230232
make_tokens_expired(access=True)
231233

@@ -282,3 +284,121 @@ def test_jwt_session_takes_priority_over_pat(disable_keyring_backend, monkeypatc
282284
result = runner.invoke(app=app, args="auth whoami")
283285
assert result.exit_code == 0
284286
assert USER_EMAIL in result.stdout
287+
288+
289+
_FAKE_PAT_NAME = "work-laptop"
290+
_FAKE_PAT = "divbase_pat_fakefakefakefakefakefakefake"
291+
292+
293+
def run_add_pat_cmd(
294+
name: str = _FAKE_PAT_NAME,
295+
expires: int | None = 9999999999,
296+
pat: str = _FAKE_PAT,
297+
overwrite: bool = False,
298+
) -> Result:
299+
"""Helper fn to run the add-pat command."""
300+
args = f"auth add-pat {name}"
301+
if expires is not None:
302+
args += f" --expires {expires}"
303+
if overwrite:
304+
args += " --overwrite-existing"
305+
return runner.invoke(
306+
app=app,
307+
args=args,
308+
input=f"{pat}\n",
309+
)
310+
311+
312+
@pytest.fixture
313+
def cleanup_stored_cli_pat():
314+
"""Remove any possibly stored PAT from a add-pat command in a test"""
315+
yield
316+
# rm-pat is idempotent, so doesn't matter if there is no PAT.
317+
result = runner.invoke(app=app, args="auth rm-pat")
318+
assert result.exit_code == 0
319+
320+
321+
def test_add_pat_stores_pat(cleanup_stored_cli_pat):
322+
"""add-pat should store the PAT and pat-info should now show the PATs is stored"""
323+
result = run_add_pat_cmd()
324+
assert result.exit_code == 0
325+
assert _FAKE_PAT_NAME in result.stdout
326+
327+
info = runner.invoke(app=app, args="auth pat-info")
328+
assert info.exit_code == 0
329+
assert _FAKE_PAT_NAME in info.stdout
330+
assert _FAKE_PAT not in info.stdout # PAT should not be printed
331+
332+
333+
def test_add_pat_rejects_invalid_pat_prefix(cleanup_stored_cli_pat):
334+
"""add-pat should fail if the token doesn't start with the PAT prefix."""
335+
result = run_add_pat_cmd(pat="not_a_real_pat")
336+
assert result.exit_code != 0
337+
assert "not a valid personal access token" in result.stdout
338+
339+
340+
def test_add_pat_rejects_already_expired_pat(cleanup_stored_cli_pat):
341+
"""add-pat should fail if the token doesn't start with the PAT prefix."""
342+
one_day_ago = datetime.now() - timedelta(days=1)
343+
result = run_add_pat_cmd(expires=int(one_day_ago.timestamp()))
344+
assert result.exit_code != 0
345+
assert "expiry" in result.stdout
346+
347+
348+
def test_add_pat_with_no_expiry(cleanup_stored_cli_pat):
349+
"""add-pat without --expires set works and stores a PAT."""
350+
result = run_add_pat_cmd(expires=None)
351+
assert result.exit_code == 0
352+
assert _FAKE_PAT_NAME in result.stdout
353+
354+
info = runner.invoke(app=app, args="auth pat-info")
355+
assert info.exit_code == 0
356+
assert _FAKE_PAT_NAME in info.stdout
357+
assert "Never" in info.stdout # expiry info
358+
assert _FAKE_PAT not in info.stdout # PAT should not be printed
359+
360+
361+
def test_cannot_add_pat_without_overwrite_flag(cleanup_stored_cli_pat):
362+
"""add-pat must refuse to overwrite an existing PAT unless --overwrite-existing is passed."""
363+
result = run_add_pat_cmd()
364+
assert result.exit_code == 0
365+
assert _FAKE_PAT_NAME in result.stdout
366+
367+
result = run_add_pat_cmd()
368+
assert result.exit_code != 0
369+
assert "already stored" in result.stdout
370+
assert "--overwrite-existing" in result.stdout
371+
372+
result = run_add_pat_cmd(overwrite=True)
373+
assert result.exit_code == 0
374+
assert _FAKE_PAT_NAME in result.stdout
375+
376+
result = run_add_pat_cmd(name="a-new-pat", overwrite=True)
377+
assert result.exit_code == 0
378+
assert "a-new-pat" in result.stdout
379+
380+
381+
def test_rm_pat_removes_stored_pat(cleanup_stored_cli_pat):
382+
"""rm-pat should remove the stored PAT so that pat-info shows nothing."""
383+
result = run_add_pat_cmd()
384+
assert result.exit_code == 0
385+
386+
result = runner.invoke(app=app, args="auth rm-pat")
387+
assert result.exit_code == 0
388+
389+
info = runner.invoke(app=app, args="auth pat-info")
390+
assert info.exit_code == 0
391+
assert "No personal access token" in info.stdout
392+
393+
394+
def test_rm_pat_with_no_pat_is_ok(cleanup_stored_cli_pat):
395+
"""rm-pat should succeed even if no PAT is currently stored."""
396+
result = runner.invoke(app=app, args="auth rm-pat")
397+
assert result.exit_code == 0
398+
399+
400+
def test_pat_info_when_no_pat_stored(cleanup_stored_cli_pat):
401+
"""pat-info should report nothing stored when no PAT has been added."""
402+
info = runner.invoke(app=app, args="auth pat-info")
403+
assert info.exit_code == 0
404+
assert "No personal access token" in info.stdout

tests/e2e_integration/cli_commands/test_personal_access_tokens.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
get_project_member in deps.py. Other more special cases like auth whoami and task-history user have their logic.
1010
1111
So below we cover a representative cmd for project scope (files ls) and then the special cases.
12+
13+
### Why mostly just env var tests
14+
A user can provide a PAT via an env var or from storing it with the add-pat command.
15+
16+
Logic to validate those cli cmds is in test_auth_cli.py.
17+
18+
We have 1 e2e test here that covers using the PAT stored by the add-pat command, otherwise we use the env var approach for convienance.
19+
These tests primarly focus on PATs being scoped correctly etc...
1220
"""
1321

1422
from datetime import datetime, timedelta, timezone
@@ -264,11 +272,32 @@ def test_expired_pat_rejected(CONSTANTS, logged_out_user_with_existing_config, p
264272
assert_401_error(result)
265273

266274

267-
def test_pat_does_not_work_on_frontend_endpoints(
268-
CONSTANTS, logged_out_user_with_existing_config, pat_factory, monkeypatch
269-
):
275+
def test_pat_stored_via_add_pat_cmd_works(CONSTANTS, logged_out_user_with_existing_config, pat_factory):
276+
"""Validate that a PAT stored via the add-pat cmd works for authentication and respects scopes."""
277+
raw_token: SecretStr = pat_factory(user_email=EDIT_USER_EMAIL, permissions=NO_SCOPE_PERMISSIONS)
278+
try:
279+
result = runner.invoke(
280+
app=app,
281+
args="auth add-pat my-pat",
282+
input=f"{raw_token.get_secret_value()}\n",
283+
)
284+
assert result.exit_code == 0
285+
286+
result = runner.invoke(app, "auth whoami")
287+
assert result.exit_code == 0
288+
assert EDIT_USER_EMAIL in result.output
289+
290+
result = runner.invoke(app, f"files ls --project {CONSTANTS['DEFAULT_PROJECT']}")
291+
assert_403_error(result)
292+
finally:
293+
# clean up the stored PAT so it doesn't interfere with other tests, even if above fails
294+
result = runner.invoke(app=app, args="auth rm-pat")
295+
assert result.exit_code == 0
296+
297+
298+
def test_pat_does_not_work_on_frontend_endpoints(CONSTANTS, logged_out_user_with_existing_config, pat_factory):
270299
"""PATs should not work on frontend endpoints"""
271-
raw_token = pat_factory(user_email=EDIT_USER_EMAIL, permissions=FULL_ACCESS_PAT_PERMISSIONS)
300+
raw_token: SecretStr = pat_factory(user_email=EDIT_USER_EMAIL, permissions=FULL_ACCESS_PAT_PERMISSIONS)
272301

273302
home_url = "http://localhost:8001/"
274303
# technically don't need the token here

tests/e2e_integration/conftest.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,26 @@ def clean_tmp_config_and_tokens_between_tests():
5252
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
5353
# tokens can either be stored in device keyring (or in a fallback file if e.g. keyring not available - likely for CI or disabled for a test)
5454
with contextlib.suppress(KeyringError):
55-
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
56-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
55+
keyring.delete_password(
56+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_TOKENS_USERNAME
57+
)
58+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
59+
with contextlib.suppress(KeyringError):
60+
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_PATS_USERNAME)
61+
cli_settings.PATS_FALLBACK_PATH.unlink(missing_ok=True)
5762

5863
yield
5964

6065
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
6166
# tokens can either be stored in device keyring (or in a fallback file if e.g. keyring not available - likely for CI or disabled for a test)
6267
with contextlib.suppress(KeyringError):
63-
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
64-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
68+
keyring.delete_password(
69+
service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_TOKENS_USERNAME
70+
)
71+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
72+
with contextlib.suppress(KeyringError):
73+
keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_PATS_USERNAME)
74+
cli_settings.PATS_FALLBACK_PATH.unlink(missing_ok=True)
6575

6676

6777
@pytest.fixture(scope="session")
@@ -268,7 +278,7 @@ def logged_in_edit_user_with_existing_config(CONSTANTS):
268278
"""Shared fixture: logged-in edit user with existing CLI config."""
269279
# ensure no config or tokens file exist before test
270280
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
271-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
281+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
272282

273283
for project in CONSTANTS["PROJECT_TO_BUCKET_MAP"]:
274284
add_command = f"config add {project}"
@@ -287,7 +297,7 @@ def logged_in_edit_user_with_existing_config(CONSTANTS):
287297
yield
288298

289299
cli_settings.CONFIG_PATH.unlink(missing_ok=True)
290-
cli_settings.TOKENS_PATH.unlink(missing_ok=True)
300+
cli_settings.TOKENS_FALLBACK_PATH.unlink(missing_ok=True)
291301

292302

293303
@pytest.fixture

0 commit comments

Comments
 (0)