Skip to content

Commit 1a20765

Browse files
JRemitzclaude
andcommitted
feat: Authenticator protocol — auth_check/auth_refresh (v0.3.0)
Implement the Authenticator capability protocol for `reeln plugins auth`. auth_check() validates R2 endpoint, bucket, and credentials. auth_refresh() reports that R2 credentials are env-var based. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2b1aa30 commit 1a20765

4 files changed

Lines changed: 231 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.3.0] - 2026-04-07
8+
9+
### Added
10+
11+
- `auth_check()` and `auth_refresh()` methods implementing the `Authenticator` protocol — enables `reeln plugins auth cloudflare` for R2 credential verification
12+
713
## [0.2.0] - 2026-03-25
814

915
### Added

reeln_cloudflare_plugin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
__version__ = "0.2.0"
5+
__version__ = "0.3.0"
66

77
from reeln_cloudflare_plugin.plugin import CloudflarePlugin
88

reeln_cloudflare_plugin/plugin.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
from typing import Any
88

9+
from reeln.models.auth import AuthCheckResult, AuthStatus
910
from reeln.models.plugin_schema import ConfigField, PluginConfigSchema
1011
from reeln.plugins.hooks import Hook, HookContext
1112
from reeln.plugins.registry import HookRegistry
@@ -24,7 +25,7 @@ class CloudflarePlugin:
2425
"""
2526

2627
name: str = "cloudflare"
27-
version: str = "0.2.0"
28+
version: str = "0.3.0"
2829
api_version: int = 1
2930

3031
config_schema: PluginConfigSchema = PluginConfigSchema(
@@ -195,6 +196,78 @@ def _resolve_credentials(self) -> tuple[str, str] | None:
195196

196197
return access_key_id, secret_access_key
197198

199+
def auth_check(self) -> list[AuthCheckResult]:
200+
"""Validate R2 credentials by connecting to the bucket."""
201+
endpoint = self._config.get("r2_endpoint", "")
202+
bucket = self._config.get("r2_bucket", "")
203+
if not endpoint or not bucket:
204+
return [AuthCheckResult(
205+
service="Cloudflare R2",
206+
status=AuthStatus.NOT_CONFIGURED,
207+
message="r2_endpoint and r2_bucket must be configured",
208+
hint="Set r2_endpoint and r2_bucket in plugin config",
209+
)]
210+
211+
creds = self._resolve_credentials()
212+
if creds is None:
213+
access_key_env = self._config.get("r2_access_key_env", "")
214+
secret_key_env = self._config.get("r2_secret_key_env", "")
215+
return [AuthCheckResult(
216+
service="Cloudflare R2",
217+
status=AuthStatus.FAIL,
218+
message=(
219+
f"Environment variables {access_key_env} and/or "
220+
f"{secret_key_env} are empty or not set"
221+
),
222+
hint=(
223+
f"Set {access_key_env} and {secret_key_env} "
224+
f"environment variables"
225+
),
226+
)]
227+
228+
access_key_id, secret_access_key = creds
229+
config = r2.R2Config(
230+
endpoint=endpoint,
231+
bucket=bucket,
232+
access_key_id=access_key_id,
233+
secret_access_key=secret_access_key,
234+
public_url_base=self._config.get("public_url_base", ""),
235+
region=self._config.get("r2_region", "auto"),
236+
)
237+
238+
try:
239+
client = r2._create_client(config)
240+
client.head_bucket(Bucket=bucket)
241+
except Exception as exc:
242+
return [AuthCheckResult(
243+
service="Cloudflare R2",
244+
status=AuthStatus.FAIL,
245+
message=f"R2 connection failed: {exc}",
246+
hint="Verify R2 credentials and endpoint",
247+
)]
248+
249+
return [AuthCheckResult(
250+
service="Cloudflare R2",
251+
status=AuthStatus.OK,
252+
message="Connected",
253+
identity=f"bucket: {bucket}",
254+
)]
255+
256+
def auth_refresh(self) -> list[AuthCheckResult]:
257+
"""R2 credentials are env-var based and cannot be refreshed."""
258+
return [AuthCheckResult(
259+
service="Cloudflare R2",
260+
status=AuthStatus.FAIL,
261+
message=(
262+
"R2 credentials are set via environment variables "
263+
"and cannot be refreshed automatically"
264+
),
265+
hint=(
266+
"Update the environment variables referenced in "
267+
"r2_access_key_env and r2_secret_key_env"
268+
),
269+
)]
270+
198271
def on_game_finish(self, context: HookContext) -> None:
199272
"""Handle ``ON_GAME_FINISH`` — reset uploaded keys list."""
200273
self._uploaded_keys = []

tests/unit/test_plugin.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,153 @@ def test_empty_config(self) -> None:
625625
def test_none_config(self) -> None:
626626
plugin = CloudflarePlugin(None)
627627
assert plugin._config == {}
628+
629+
630+
# ------------------------------------------------------------------
631+
# auth_check
632+
# ------------------------------------------------------------------
633+
634+
635+
class TestAuthCheckNotConfigured:
636+
def test_missing_endpoint(self) -> None:
637+
plugin = CloudflarePlugin({"r2_endpoint": "", "r2_bucket": "b"})
638+
results = plugin.auth_check()
639+
assert len(results) == 1
640+
assert results[0].service == "Cloudflare R2"
641+
assert results[0].status.value == "not_configured"
642+
assert "r2_endpoint" in results[0].message
643+
644+
def test_missing_bucket(self) -> None:
645+
plugin = CloudflarePlugin({"r2_endpoint": "https://x.r2.cloudflarestorage.com", "r2_bucket": ""})
646+
results = plugin.auth_check()
647+
assert len(results) == 1
648+
assert results[0].status.value == "not_configured"
649+
assert "r2_bucket" in results[0].message
650+
651+
def test_both_missing(self) -> None:
652+
plugin = CloudflarePlugin({})
653+
results = plugin.auth_check()
654+
assert len(results) == 1
655+
assert results[0].status.value == "not_configured"
656+
assert "r2_endpoint" in results[0].hint
657+
658+
659+
class TestAuthCheckEnvVarsNotSet:
660+
def test_env_vars_missing(
661+
self,
662+
plugin_config: dict[str, Any],
663+
monkeypatch: pytest.MonkeyPatch,
664+
) -> None:
665+
monkeypatch.delenv("R2_ACCESS_KEY_ID", raising=False)
666+
monkeypatch.delenv("R2_SECRET_ACCESS_KEY", raising=False)
667+
plugin = CloudflarePlugin(plugin_config)
668+
results = plugin.auth_check()
669+
assert len(results) == 1
670+
assert results[0].service == "Cloudflare R2"
671+
assert results[0].status.value == "fail"
672+
assert "R2_ACCESS_KEY_ID" in results[0].message
673+
assert "R2_SECRET_ACCESS_KEY" in results[0].message
674+
675+
def test_env_var_names_in_hint(
676+
self,
677+
plugin_config: dict[str, Any],
678+
monkeypatch: pytest.MonkeyPatch,
679+
) -> None:
680+
monkeypatch.delenv("R2_ACCESS_KEY_ID", raising=False)
681+
monkeypatch.delenv("R2_SECRET_ACCESS_KEY", raising=False)
682+
plugin = CloudflarePlugin(plugin_config)
683+
results = plugin.auth_check()
684+
assert "R2_ACCESS_KEY_ID" in results[0].hint
685+
assert "R2_SECRET_ACCESS_KEY" in results[0].hint
686+
687+
688+
class TestAuthCheckHeadBucketFails:
689+
@patch("reeln_cloudflare_plugin.r2._create_client")
690+
def test_head_bucket_client_error(
691+
self,
692+
mock_create: Any,
693+
plugin_config: dict[str, Any],
694+
r2_env: None,
695+
) -> None:
696+
from botocore.exceptions import ClientError
697+
698+
mock_client = mock_create.return_value
699+
mock_client.head_bucket.side_effect = ClientError(
700+
{"Error": {"Code": "403", "Message": "Forbidden"}},
701+
"HeadBucket",
702+
)
703+
plugin = CloudflarePlugin(plugin_config)
704+
results = plugin.auth_check()
705+
assert len(results) == 1
706+
assert results[0].status.value == "fail"
707+
assert "R2 connection failed" in results[0].message
708+
assert "Verify R2 credentials" in results[0].hint
709+
710+
@patch("reeln_cloudflare_plugin.r2._create_client")
711+
def test_head_bucket_generic_exception(
712+
self,
713+
mock_create: Any,
714+
plugin_config: dict[str, Any],
715+
r2_env: None,
716+
) -> None:
717+
mock_client = mock_create.return_value
718+
mock_client.head_bucket.side_effect = RuntimeError("timeout")
719+
plugin = CloudflarePlugin(plugin_config)
720+
results = plugin.auth_check()
721+
assert len(results) == 1
722+
assert results[0].status.value == "fail"
723+
assert "timeout" in results[0].message
724+
725+
726+
class TestAuthCheckSuccess:
727+
@patch("reeln_cloudflare_plugin.r2._create_client")
728+
def test_connected_ok(
729+
self,
730+
mock_create: Any,
731+
plugin_config: dict[str, Any],
732+
r2_env: None,
733+
) -> None:
734+
mock_client = mock_create.return_value
735+
mock_client.head_bucket.return_value = {}
736+
plugin = CloudflarePlugin(plugin_config)
737+
results = plugin.auth_check()
738+
assert len(results) == 1
739+
assert results[0].service == "Cloudflare R2"
740+
assert results[0].status.value == "ok"
741+
assert results[0].message == "Connected"
742+
assert results[0].identity == "bucket: test-bucket"
743+
744+
@patch("reeln_cloudflare_plugin.r2._create_client")
745+
def test_head_bucket_called_with_correct_bucket(
746+
self,
747+
mock_create: Any,
748+
plugin_config: dict[str, Any],
749+
r2_env: None,
750+
) -> None:
751+
mock_client = mock_create.return_value
752+
mock_client.head_bucket.return_value = {}
753+
plugin = CloudflarePlugin(plugin_config)
754+
plugin.auth_check()
755+
mock_client.head_bucket.assert_called_once_with(Bucket="test-bucket")
756+
757+
758+
# ------------------------------------------------------------------
759+
# auth_refresh
760+
# ------------------------------------------------------------------
761+
762+
763+
class TestAuthRefresh:
764+
def test_always_returns_fail(self) -> None:
765+
plugin = CloudflarePlugin()
766+
results = plugin.auth_refresh()
767+
assert len(results) == 1
768+
assert results[0].service == "Cloudflare R2"
769+
assert results[0].status.value == "fail"
770+
assert "cannot be refreshed" in results[0].message
771+
772+
def test_hint_mentions_env_vars(self) -> None:
773+
plugin = CloudflarePlugin()
774+
results = plugin.auth_refresh()
775+
assert "environment variables" in results[0].hint
776+
assert "r2_access_key_env" in results[0].hint
777+
assert "r2_secret_key_env" in results[0].hint

0 commit comments

Comments
 (0)