Files
connpy/connpy/tests/test_user_api_tokens.py
T
fluzzi32 fe189885d4 feat(auth,ai): add Personal Access Tokens support and persist
AI copilot context state

    - Implement Personal Access Token (PAT) management in UserService with SHA-256
  hash storage and O(1) lookup.
    - Add gRPC endpoints (CreateApiToken, ListApiTokens, RevokeApiToken) and CLI
  commands.
    - Allow authentication using CONNPY_TOKEN environment variable in
  ServiceProvider.
    - Persist AI Copilot context mode and accumulation ranges across prompt
  sessions in terminal_ui.
    - Propagate node_info_json metadata over Copilot gRPC tunnel stream.
    - Add unit tests for PAT lifecycle and Copilot context state persistence (430
  passing tests).
2026-08-11 11:40:12 -03:00

217 lines
8.7 KiB
Python

import os
import datetime
import hashlib
import pytest
import yaml
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def user_service(test_config_dir):
"""Initializes UserService pointing to a temporary directory."""
return UserService(str(test_config_dir))
@pytest.fixture
def user_with_token(user_service):
"""Creates a user and returns (user_service, username, token_result)."""
username = "tokenuser"
user_service.create_user(username, "password123")
result = user_service.create_api_token(username, "Test Token")
return user_service, username, result
class TestApiTokenCreation:
def test_create_api_token_returns_raw_token(self, user_service):
"""Verifies that create_api_token returns a raw token with the correct prefix."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "CI Pipeline")
assert "raw_token" in result
assert result["raw_token"].startswith("cnp_pat_")
assert len(result["raw_token"]) > 16
assert "token_id" in result
assert result["token_id"].startswith("tok_")
assert result["name"] == "CI Pipeline"
def test_create_api_token_stores_hash_not_plaintext(self, user_service):
"""Ensures only the SHA-256 hash is persisted, never the raw token."""
user_service.create_user("bob", "pass")
result = user_service.create_api_token("bob", "My App")
registry = user_service._load_registry()
tokens = registry["users"]["bob"]["api_tokens"]
assert len(tokens) == 1
token_meta = list(tokens.values())[0]
expected_hash = hashlib.sha256(result["raw_token"].encode("utf-8")).hexdigest()
assert token_meta["token_hash"] == expected_hash
# Raw token must NOT be stored
assert result["raw_token"] not in str(token_meta)
def test_create_api_token_with_expiration(self, user_service):
"""Verifies that expires_at is set correctly when expires_in_days is provided."""
user_service.create_user("charlie", "pass")
user_service.create_api_token("charlie", "Temp Token", expires_in_days=30)
registry = user_service._load_registry()
token_meta = list(registry["users"]["charlie"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is not None
exp_dt = datetime.datetime.fromisoformat(token_meta["expires_at"])
now = datetime.datetime.now(datetime.timezone.utc)
delta = exp_dt - now
assert 29 <= delta.days <= 30
def test_create_api_token_permanent_by_default(self, user_service):
"""Verifies that expires_at is None when no expiration is specified."""
user_service.create_user("dave", "pass")
user_service.create_api_token("dave", "Permanent Token")
registry = user_service._load_registry()
token_meta = list(registry["users"]["dave"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is None
def test_create_api_token_nonexistent_user(self, user_service):
"""Ensures creating a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.create_api_token("ghost", "Token")
def test_create_api_token_empty_name(self, user_service):
"""Ensures empty token names are rejected."""
user_service.create_user("eve", "pass")
with pytest.raises(ValueError, match="cannot be empty"):
user_service.create_api_token("eve", "")
def test_create_multiple_tokens(self, user_service):
"""Verifies a user can have multiple tokens."""
user_service.create_user("frank", "pass")
t1 = user_service.create_api_token("frank", "Token 1")
t2 = user_service.create_api_token("frank", "Token 2")
assert t1["token_id"] != t2["token_id"]
assert t1["raw_token"] != t2["raw_token"]
tokens = user_service.list_api_tokens("frank")
assert len(tokens) == 2
class TestApiTokenVerification:
def test_verify_valid_token(self, user_with_token):
"""Verifies that a valid raw token authenticates correctly."""
svc, username, result = user_with_token
verified = svc.verify_api_token(result["raw_token"])
assert verified == username
def test_verify_invalid_token(self, user_service):
"""Verifies that a random/invalid token returns None."""
user_service.create_user("alice", "pass")
assert user_service.verify_api_token("cnp_pat_invalid_token_here") is None
def test_verify_expired_token(self, user_service):
"""Verifies that an expired token returns None."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "Expiring", expires_in_days=1)
# Manually set expires_at to the past
registry = user_service._load_registry()
token_meta = list(registry["users"]["alice"]["api_tokens"].values())[0]
token_meta["expires_at"] = (
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
).isoformat()
user_service._save_registry(registry)
# Invalidate cache so verify_api_token re-reads
user_service._token_index = {}
assert user_service.verify_api_token(result["raw_token"]) is None
def test_verify_updates_last_used_at(self, user_with_token):
"""Verifies that last_used_at is updated upon successful verification."""
svc, username, result = user_with_token
# Initially last_used_at should be None
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is None
# Verify the token
svc.verify_api_token(result["raw_token"])
# Now last_used_at should be set
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is not None
class TestApiTokenListing:
def test_list_tokens_returns_metadata(self, user_with_token):
"""Verifies list returns metadata without sensitive data."""
svc, username, result = user_with_token
tokens = svc.list_api_tokens(username)
assert len(tokens) == 1
t = tokens[0]
assert t["token_id"] == result["token_id"]
assert t["name"] == "Test Token"
assert t["token_prefix"].startswith("cnp_pat_")
assert "created_at" in t
# Must NOT expose token_hash or raw_token
assert "token_hash" not in t
assert "raw_token" not in t
def test_list_tokens_empty(self, user_service):
"""Verifies listing tokens for a user with none returns empty list."""
user_service.create_user("alice", "pass")
assert user_service.list_api_tokens("alice") == []
def test_list_tokens_nonexistent_user(self, user_service):
"""Ensures listing tokens for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.list_api_tokens("ghost")
class TestApiTokenRevocation:
def test_revoke_token(self, user_with_token):
"""Verifies that a revoked token is immediately invalid."""
svc, username, result = user_with_token
# Token works before revocation
assert svc.verify_api_token(result["raw_token"]) == username
# Revoke
removed = svc.revoke_api_token(username, result["token_id"])
assert removed is True
# Token must fail after revocation
assert svc.verify_api_token(result["raw_token"]) is None
# List should be empty
assert svc.list_api_tokens(username) == []
def test_revoke_nonexistent_token(self, user_service):
"""Verifies revoking a non-existent token returns False."""
user_service.create_user("alice", "pass")
assert user_service.revoke_api_token("alice", "tok_nonexistent") is False
def test_revoke_nonexistent_user(self, user_service):
"""Ensures revoking a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.revoke_api_token("ghost", "tok_abc")
class TestJwtUnchanged:
def test_jwt_still_works(self, user_service):
"""Confirms that existing JWT session tokens still authenticate correctly."""
user_service.create_user("jwtuser", "pass")
token = user_service.generate_jwt("jwtuser")
verified = user_service.verify_jwt(token)
assert verified == "jwtuser"