Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
241 lines
7.5 KiB
Python
241 lines
7.5 KiB
Python
"""Tests for the SecretUpdater module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from nacl.public import PrivateKey
|
|
|
|
from scripts.pat_manager.errors import SecretUpdateError
|
|
from scripts.pat_manager.secret_updater import SecretUpdater
|
|
|
|
|
|
@pytest.fixture
|
|
def keypair():
|
|
"""Generate a NaCl keypair for testing encryption."""
|
|
private_key = PrivateKey.generate()
|
|
public_key = private_key.public_key
|
|
public_key_b64 = base64.b64encode(bytes(public_key)).decode("utf-8")
|
|
return private_key, public_key, public_key_b64
|
|
|
|
|
|
@pytest.fixture
|
|
def updater():
|
|
"""Create a SecretUpdater instance for testing."""
|
|
return SecretUpdater(
|
|
github_token="ghp_test_token_123",
|
|
repo_owner="test-owner",
|
|
repo_name="test-repo",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_success(updater, keypair):
|
|
"""Test successful secret update flow."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
# Mock GET public key endpoint
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-123"},
|
|
)
|
|
)
|
|
|
|
# Mock PUT secret endpoint
|
|
respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
|
).mock(return_value=httpx.Response(204))
|
|
|
|
result = await updater.update_secret("MY_SECRET", "super-secret-value")
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_created_201(updater, keypair):
|
|
"""Test secret creation returns True on 201."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-456"},
|
|
)
|
|
)
|
|
|
|
respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/NEW_SECRET"
|
|
).mock(return_value=httpx.Response(201))
|
|
|
|
result = await updater.update_secret("NEW_SECRET", "new-value")
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_public_key_fetch_fails_http_error(updater):
|
|
"""Test SecretUpdateError raised when public key fetch returns non-200."""
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(return_value=httpx.Response(403))
|
|
|
|
with pytest.raises(SecretUpdateError) as exc_info:
|
|
await updater.update_secret("MY_SECRET", "value")
|
|
|
|
assert "public key" in str(exc_info.value).lower()
|
|
assert "403" in str(exc_info.value)
|
|
# Ensure token value is NOT in the error message
|
|
assert "value" not in str(exc_info.value) or "secret value" not in str(
|
|
exc_info.value
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_public_key_network_error(updater):
|
|
"""Test SecretUpdateError raised on network failure during key fetch."""
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(side_effect=httpx.ConnectError("Connection refused"))
|
|
|
|
with pytest.raises(SecretUpdateError) as exc_info:
|
|
await updater.update_secret("MY_SECRET", "value")
|
|
|
|
assert "MY_SECRET" in str(exc_info.value)
|
|
assert "ConnectError" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_put_fails_http_error(updater, keypair):
|
|
"""Test SecretUpdateError raised when PUT returns unexpected status."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-789"},
|
|
)
|
|
)
|
|
|
|
respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
|
).mock(return_value=httpx.Response(422))
|
|
|
|
with pytest.raises(SecretUpdateError) as exc_info:
|
|
await updater.update_secret("MY_SECRET", "secret-token-value")
|
|
|
|
assert "422" in str(exc_info.value)
|
|
# Ensure the actual secret value is NOT exposed in the error
|
|
assert "secret-token-value" not in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_put_network_error(updater, keypair):
|
|
"""Test SecretUpdateError raised on network failure during PUT."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-000"},
|
|
)
|
|
)
|
|
|
|
respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
|
).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
|
|
|
with pytest.raises(SecretUpdateError) as exc_info:
|
|
await updater.update_secret("MY_SECRET", "value")
|
|
|
|
assert "MY_SECRET" in str(exc_info.value)
|
|
assert "TimeoutException" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_does_not_expose_token_value(updater, keypair):
|
|
"""Verify that error messages never contain the secret value."""
|
|
_, _, public_key_b64 = keypair
|
|
sensitive_value = "ghp_SUPER_SECRET_TOKEN_12345"
|
|
|
|
respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-sec"},
|
|
)
|
|
)
|
|
|
|
respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/TOKEN_SECRET"
|
|
).mock(return_value=httpx.Response(500))
|
|
|
|
with pytest.raises(SecretUpdateError) as exc_info:
|
|
await updater.update_secret("TOKEN_SECRET", sensitive_value)
|
|
|
|
error_str = str(exc_info.value)
|
|
assert sensitive_value not in error_str
|
|
assert "ghp_SUPER_SECRET" not in error_str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_encrypt_secret_produces_valid_output(updater, keypair):
|
|
"""Test that _encrypt_secret produces base64-encoded encrypted data."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
encrypted = updater._encrypt_secret(public_key_b64, "test-secret")
|
|
|
|
# Should be valid base64
|
|
decoded = base64.b64decode(encrypted)
|
|
# NaCl sealed box output is always 48 bytes longer than the plaintext
|
|
# (32 bytes ephemeral public key + 16 bytes MAC)
|
|
assert len(decoded) == len("test-secret".encode("utf-8")) + 48
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_update_secret_sends_correct_headers(updater, keypair):
|
|
"""Verify correct authorization headers are sent."""
|
|
_, _, public_key_b64 = keypair
|
|
|
|
key_route = respx.get(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"key": public_key_b64, "key_id": "key-id-hdr"},
|
|
)
|
|
)
|
|
|
|
put_route = respx.put(
|
|
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/HDR_SECRET"
|
|
).mock(return_value=httpx.Response(204))
|
|
|
|
await updater.update_secret("HDR_SECRET", "value")
|
|
|
|
# Verify authorization header was sent
|
|
assert key_route.called
|
|
key_request = key_route.calls[0].request
|
|
assert key_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|
|
|
|
assert put_route.called
|
|
put_request = put_route.calls[0].request
|
|
assert put_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|