From 337e8113ad7ec70bb8474ce83d7a6996d0404dfb Mon Sep 17 00:00:00 2001 From: JS Ng Date: Sun, 4 Oct 2026 09:24:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20complete=20auth=20coverage=20?= =?UTF-8?q?=E2=80=94=20access.check,=20Clients.new=20scope=20fields,=20Cre?= =?UTF-8?q?dentials.User.new?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clients.Client.access.check(vault, permission): GET the /access/check leaf route with vault/permission query params - Clients.new(): forward allowed_scopes, upstream_scopes, token_bridge (update() has supported them since PR #65) - Credentials.Provider.User.new(scopes, expires_in): implement the stale NotImplementedError stub — POST /credentials// sends {scopes, expires_in}; the client_id comes from the request's auth context, not the body Fixes #76 --- campus_python/auth/v1/clients.py | 37 ++++++- campus_python/auth/v1/credentials.py | 17 +++- tests/unit/test_auth_coverage.py | 139 +++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_auth_coverage.py diff --git a/campus_python/auth/v1/clients.py b/campus_python/auth/v1/clients.py index 0ce2172..fedcfc9 100644 --- a/campus_python/auth/v1/clients.py +++ b/campus_python/auth/v1/clients.py @@ -31,7 +31,10 @@ def new( name: str, description: str, is_public: bool = False, - redirect_uris: list[str] | None = None + redirect_uris: list[str] | None = None, + allowed_scopes: list[str] | None = None, + upstream_scopes: dict[str, list[str]] | None = None, + token_bridge: bool | None = None ) -> campus.model.Client: """Create a new client. @@ -40,16 +43,28 @@ def new( description: Client description is_public: True for public clients (CLI/mobile apps) without secrets redirect_uris: OAuth redirect URIs for public clients + allowed_scopes: Token-scope allowlist (fail-closed: an + empty list grants nothing) + upstream_scopes: Per-provider upstream scope map + (e.g. {"google": [...]}) + token_bridge: Token bridge access flag (upstream token + release; rejected by the server for public clients) Returns: The created Client """ - json_data = { + json_data: JsonDict = { "name": name, "description": description, "is_public": is_public, "redirect_uris": redirect_uris or [] } + if allowed_scopes is not None: + json_data["allowed_scopes"] = allowed_scopes + if upstream_scopes is not None: + json_data["upstream_scopes"] = upstream_scopes + if token_bridge is not None: + json_data["token_bridge"] = token_bridge resp = self.client.post(self.make_path(), json=json_data) # Raise error if status code is not 2XX or 3XX resp.raise_for_status() @@ -158,6 +173,24 @@ def get( resp.raise_for_status() return resp.json() + def check( + self, + vault: str, + permission: int, + ) -> JsonDict: + """Check whether the client holds a permission on a vault. + + GET //access/check?vault=&permission= + Returns: {"vault": ..., "permission": } + """ + resp = self.client.get( + self.make_path("check", end_slash=False), + query={"vault": vault, "permission": permission} + ) + # Raise error if status code is not 2XX or 3XX + resp.raise_for_status() + return resp.json() + def grant( self, vault: str, diff --git a/campus_python/auth/v1/credentials.py b/campus_python/auth/v1/credentials.py index cd3db08..3747234 100644 --- a/campus_python/auth/v1/credentials.py +++ b/campus_python/auth/v1/credentials.py @@ -82,9 +82,22 @@ def new( scopes: "list[str]", expires_in: int, ) -> campus.model.UserCredentials: - raise NotImplementedError( - "Method not expected to be called on API" + """Issue new credentials for this provider and user. + + POST /credentials// with body + {scopes, expires_in}; the client_id comes from the + request's auth context, not the body. + + Returns: + The created UserCredentials + """ + resp = self.client.post( + self.make_path(), + json={"scopes": scopes, "expires_in": expires_in} ) + # Raise error if status code is not 2XX or 3XX + resp.raise_for_status() + return campus.model.UserCredentials.from_resource(resp.json()) def update(self, token: campus.model.OAuthToken) -> None: client_id = env.CLIENT_ID diff --git a/tests/unit/test_auth_coverage.py b/tests/unit/test_auth_coverage.py new file mode 100644 index 0000000..d2e9db3 --- /dev/null +++ b/tests/unit/test_auth_coverage.py @@ -0,0 +1,139 @@ +"""Contract tests for the auth coverage remainder (issue #76). + +- Clients.Client.access.check() — GET /auth/v1/clients//access/check + (leaf path, no trailing slash; vault/permission as query params) +- Clients.new() — POST /auth/v1/clients/ also accepts allowed_scopes, + upstream_scopes, token_bridge (as the server route does) +- Credentials.Provider.User.new() — POST /auth/v1/credentials/ + / with {scopes, expires_in}; the client_id comes + from the request's auth context, not the body +""" + +import unittest +from unittest.mock import Mock + +import campus.model + +from campus_python.auth.v1 import AuthRoot + +# Client resource shape emitted by campus weekly +# (campus/auth/routes/clients.py responses). +CLIENT_RESOURCE = { + "id": "cid123", + "created_at": "2026-09-30T06:31:24.582763+00:00", + "name": "campus-app", + "description": "Server-mode app", + "is_public": False, + "redirect_uris": [], +} + +# UserCredentials resource (scope-only token emission, campus #657). +CREDENTIALS_RESOURCE = { + "id": "cred1", + "created_at": "2026-09-30T06:31:24.582763+00:00", + "provider": "campus", + "client_id": "cid123", + "user_id": "user1", + "token": { + "id": "tok123", + "created_at": "2026-09-30T06:31:24.582763+00:00", + "expires_at": "2026-09-30T07:31:24.582763+00:00", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "rt123", + "refresh_token_expires_at": None, + "scope": "campus.profile campus.identities", + }, +} + + +def make_auth() -> tuple[AuthRoot, Mock]: + """Create an AuthRoot backed by a mock JSON client.""" + client = Mock() + return AuthRoot(json_client=client), client + + +class TestClientAccessCheck(unittest.TestCase): + """access.check() must GET the /access/check leaf route.""" + + def setUp(self): + self.auth, self.client = make_auth() + self.client.get.return_value.json.return_value = { + "vault": "campus.vault", "permission": True + } + + def test_check_sends_vault_and_permission_as_query(self): + result = self.auth.clients["cid123"].access.check( + vault="campus.vault", permission=3 + ) + self.client.get.assert_called_once_with( + "/auth/v1/clients/cid123/access/check", + query={"vault": "campus.vault", "permission": 3}, + ) + self.assertEqual(result, {"vault": "campus.vault", "permission": True}) + + +class TestClientsNewScopeFields(unittest.TestCase): + """Clients.new() must forward the scope/bridge registration fields.""" + + def setUp(self): + self.auth, self.client = make_auth() + self.client.post.return_value.json.return_value = CLIENT_RESOURCE + + def test_new_sends_scope_fields_when_given(self): + self.auth.clients.new( + name="campus-app", + description="Server-mode app", + allowed_scopes=["campus.api"], + upstream_scopes={"google": ["https://www.googleapis.com/auth/calendar"]}, + token_bridge=True, + ) + self.assertEqual( + self.client.post.call_args.kwargs["json"], + { + "name": "campus-app", + "description": "Server-mode app", + "is_public": False, + "redirect_uris": [], + "allowed_scopes": ["campus.api"], + "upstream_scopes": { + "google": ["https://www.googleapis.com/auth/calendar"] + }, + "token_bridge": True, + }, + ) + + def test_new_omits_unset_scope_fields(self): + self.auth.clients.new(name="campus-app", description="d") + self.assertEqual( + self.client.post.call_args.kwargs["json"], + { + "name": "campus-app", + "description": "d", + "is_public": False, + "redirect_uris": [], + }, + ) + + +class TestCredentialsUserNew(unittest.TestCase): + """User.new() must POST the live endpoint instead of raising.""" + + def setUp(self): + self.auth, self.client = make_auth() + self.client.post.return_value.json.return_value = CREDENTIALS_RESOURCE + + def test_new_posts_scopes_and_expires_in(self): + creds = self.auth.credentials["campus"]["user1"].new( + scopes=["campus.profile"], expires_in=3600 + ) + self.client.post.assert_called_once_with( + "/auth/v1/credentials/campus/user1", + json={"scopes": ["campus.profile"], "expires_in": 3600}, + ) + self.assertIsInstance(creds, campus.model.UserCredentials) + self.assertEqual(creds.token.id, "tok123") + + +if __name__ == "__main__": + unittest.main() From 213a5406a09fe33dd61211774fb5ace99137916d Mon Sep 17 00:00:00 2001 From: JS Ng Date: Sun, 4 Oct 2026 09:28:17 +0800 Subject: [PATCH 2/2] feat(client): add client.integrations.list() for the integrations registry (#56) Read-only resource client for the public auth-service catalog at /integrations/v1/ (campus#751), returning campus.model.Integration entries (added to campus weekly in campus#784). The registry schema supersedes the issue sketch: provider, slug, base_provider, title, description, scopes, connectable, authorize_path (no api_doc/ capabilities). Shares the auth client so it works in device mode. Closes #56 --- README.md | 6 ++ campus_python/__init__.py | 16 ++++ campus_python/integrations/v1/__init__.py | 43 +++++++++ poetry.lock | 2 +- tests/unit/test_integrations.py | 109 ++++++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 campus_python/integrations/v1/__init__.py create mode 100644 tests/unit/test_integrations.py diff --git a/README.md b/README.md index fbbd4dd..a8c6f4e 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ client = Campus(timeout=30, mode="server") # For device/public clients client = Campus(timeout=30, mode="device") + +# Discover the first-party integrations Campus offers (public, read-only) +for integration in client.integrations.list(): + print(integration.provider, integration.connectable) ``` ## Project Structure @@ -94,6 +98,8 @@ campus-api-python/ │ │ ├── sessions.py │ │ ├── users.py │ │ └── vaults.py +│ ├── integrations/ +│ │ └── v1/ # Integrations registry (auth service, public) │ └── json_client/ # JSON client implementation │ ├── __init__.py │ └── interface.py diff --git a/campus_python/__init__.py b/campus_python/__init__.py index 985c896..fc5e101 100644 --- a/campus_python/__init__.py +++ b/campus_python/__init__.py @@ -19,6 +19,7 @@ from . import errors from .api.v1 import ApiRoot from .auth.v1 import AuthRoot +from .integrations.v1 import IntegrationsRoot from .json_client import CampusRequest logging.basicConfig(level=logging.INFO) @@ -138,6 +139,21 @@ def api(self) -> ApiRoot: ) return self._api + @property + def integrations(self) -> IntegrationsRoot: + """Get the integrations registry resource (auth service). + + Public, read-only catalog of first-party integrations (#56), + served by the auth service at /integrations/v1 (outside + /auth/v1); shares the auth client so it works in device mode + too. + """ + if not hasattr(self, "_integrations"): + self._integrations = IntegrationsRoot( + json_client=self.auth.client + ) + return self._integrations + def _get_token_from_session( self, force_refresh=False, diff --git a/campus_python/integrations/v1/__init__.py b/campus_python/integrations/v1/__init__.py new file mode 100644 index 0000000..f191d41 --- /dev/null +++ b/campus_python/integrations/v1/__init__.py @@ -0,0 +1,43 @@ +"""campus.python.integrations.v1 + +Campus integrations registry resource (v1). + +Read-only service catalog of the first-party integrations Campus +offers, served by the auth service at /integrations/v1 (#688, +#56). The endpoint is public — everything it returns is already +observable by starting a connect flow — so the resource works in +both server and device modes without extra authorization. +""" + +import campus.model + +from ...interface import ResourceRoot + + +class IntegrationsRoot(ResourceRoot): + """Campus integrations registry resource (auth service, v1). + + Usage: + client.integrations.list() # -> list[campus.model.Integration] + """ + + # Trailing slash matches the Flask route (GET /integrations/v1/); + # make_path() preserves it verbatim. + url_prefix: str = "/integrations/v1/" + + def list(self) -> "list[campus.model.Integration]": + """List the first-party integrations Campus offers. + + Returns: + list[campus.model.Integration]: Registry entries with + public catalog metadata only (no secrets). If + enable/disable flags are added server-side later, they + surface behind this same resource. + """ + resp = self.client.get(self.make_path()) + # Raise error if status code is not 2XX or 3XX + resp.raise_for_status() + return [ + campus.model.Integration.from_resource(item) + for item in resp.json().get("integrations", []) + ] diff --git a/poetry.lock b/poetry.lock index 2eae3e8..05d1d57 100644 --- a/poetry.lock +++ b/poetry.lock @@ -142,7 +142,7 @@ werkzeug = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus.git" reference = "weekly" -resolved_reference = "08d901cefe49f560324745415e9428d550f7cb3e" +resolved_reference = "518a9fa92bec5c173d5b709a577ad34733ff4237" [[package]] name = "certifi" diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py new file mode 100644 index 0000000..e89a68d --- /dev/null +++ b/tests/unit/test_integrations.py @@ -0,0 +1,109 @@ +"""Tests for the integrations registry resource (issue #56). + +client.integrations.list() hits the public auth-service catalog at +/integrations/v1/ (outside /auth/v1) and returns +campus.model.Integration entries. +""" + +import unittest +from unittest.mock import Mock + +import campus.model + +import campus_python +from campus_python.integrations.v1 import IntegrationsRoot + +REGISTRY_RESOURCE = { + "integrations": [ + { + "provider": "google.classroom", + "slug": "classroom", + "base_provider": "google", + "title": "Google Classroom", + "description": "Connect Google Classroom for coursework tooling.", + "scopes": ["classroom.rosters"], + "connectable": True, + "authorize_path": "/auth/v1/google/classroom/authorize", + }, + { + "provider": "github", + "slug": "github", + "base_provider": "github", + "title": "GitHub", + "description": "Connect GitHub for repository tooling.", + "scopes": [], + "connectable": False, + "authorize_path": "/auth/v1/github/github/authorize", + }, + ] +} + + +def make_integrations() -> tuple[IntegrationsRoot, Mock]: + """Create an IntegrationsRoot backed by a mock JSON client.""" + client = Mock() + return IntegrationsRoot(json_client=client), client + + +class TestIntegrationsPaths(unittest.TestCase): + """The registry resource must request its own API endpoint.""" + + def setUp(self): + self.integrations, _ = make_integrations() + + def test_list_path_is_top_level_versioned(self): + """Registry sits at /integrations/v1/, not under /auth/v1.""" + self.assertEqual(self.integrations.make_path(), "/integrations/v1/") + + def test_url_prefix_keeps_trailing_slash(self): + """The Flask route is GET /integrations/v1/ (trailing slash).""" + self.assertTrue(self.integrations.make_path().endswith("/")) + + +class TestIntegrationsList(unittest.TestCase): + """list() returns campus.model.Integration entries.""" + + def setUp(self): + self.integrations, self.client = make_integrations() + self.client.get.return_value.json.return_value = REGISTRY_RESOURCE + + def test_list_requests_registry_endpoint(self): + self.integrations.list() + self.client.get.assert_called_once_with("/integrations/v1/") + + def test_list_returns_integration_models(self): + result = self.integrations.list() + self.assertEqual(len(result), 2) + for item in result: + self.assertIsInstance(item, campus.model.Integration) + self.assertEqual(result[0].provider, "google.classroom") + self.assertEqual(result[0].slug, "classroom") + self.assertEqual(result[0].base_provider, "google") + self.assertTrue(result[0].connectable) + self.assertEqual(result[1].title, "GitHub") + self.assertFalse(result[1].connectable) + self.assertEqual( + result[0].authorize_path, + "/auth/v1/google/classroom/authorize", + ) + + def test_list_tolerates_missing_envelope_key(self): + self.client.get.return_value.json.return_value = {} + self.assertEqual(self.integrations.list(), []) + + +class TestCampusIntegrationsMounting(unittest.TestCase): + """client.integrations is mounted on Campus and shares the auth client.""" + + def test_integrations_mounted_on_campus(self): + campus = campus_python.Campus(timeout=60, mode="device") + self.assertIsInstance(campus.integrations, IntegrationsRoot) + + def test_integrations_shares_auth_client(self): + """Registry is served by the auth service; reuse its client.""" + campus = campus_python.Campus(timeout=60, mode="device") + self.assertIs(campus.integrations.client, campus.auth.client) + + def test_integrations_is_cached(self): + campus = campus_python.Campus(timeout=60, mode="device") + self.assertIs(campus.integrations, campus.integrations)