diff --git a/campus_python/auth/v1/clients.py b/campus_python/auth/v1/clients.py index 1659768..a18f6a3 100644 --- a/campus_python/auth/v1/clients.py +++ b/campus_python/auth/v1/clients.py @@ -95,14 +95,27 @@ def update( self, name: str | None = None, description: str | None = None, - 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: """Update the client. + Only the fields passed are sent; the server replaces each + provided field wholesale (PATCH semantics), so list and + dict values must carry the full desired contents. + Args: name: New client name description: New client description redirect_uris: New OAuth redirect URIs + allowed_scopes: Replacement token-scope allowlist + (fail-closed: an empty list grants nothing) + upstream_scopes: Replacement per-provider upstream + scope map (e.g. {"google": [...]}) + token_bridge: Token bridge access flag (rejected by the + server for public clients) Returns: The updated Client @@ -114,6 +127,12 @@ def update( json_data["description"] = description if redirect_uris is not None: json_data["redirect_uris"] = redirect_uris + 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.patch(self.make_path(), json=json_data) # Raise error if status code is not 2XX or 3XX resp.raise_for_status() diff --git a/poetry.lock b/poetry.lock index e616760..2eae3e8 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 = "ff98c44a7c6a1bdd4f7dabcaea46ca254108f6ee" +resolved_reference = "08d901cefe49f560324745415e9428d550f7cb3e" [[package]] name = "certifi" diff --git a/tests/unit/test_auth_clients.py b/tests/unit/test_auth_clients.py index 156ad0e..5041637 100644 --- a/tests/unit/test_auth_clients.py +++ b/tests/unit/test_auth_clients.py @@ -24,6 +24,20 @@ "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"], } +# The scope/bridge admin fields (campus-cli#24) as the PATCH response +# carries them once set. +CLIENT_RESOURCE_WITH_SCOPES = { + **CLIENT_RESOURCE, + "allowed_scopes": ["openid", "campus.api"], + "upstream_scopes": { + "google": ["https://www.googleapis.com/auth/calendar"], + "google.classroom": [ + "https://www.googleapis.com/auth/classroom.rosters", + ], + }, + "token_bridge": True, +} + def make_auth() -> tuple[AuthRoot, Mock]: """Create an AuthRoot backed by a mock JSON client.""" @@ -65,5 +79,84 @@ def test_update_omits_unset_fields(self): ) +class TestClientsUpdateScopeBridgeFields(unittest.TestCase): + """Clients.Client.update() forwards the scope/bridge admin fields + (campus-cli#24): allowed_scopes, upstream_scopes and token_bridge. + + The server PATCH full-replaces each provided field, so the SDK + must send exactly what the caller passed, and nothing for fields + left unset. + """ + + def setUp(self): + self.auth, self.client = make_auth() + response = Mock() + response.json.return_value = CLIENT_RESOURCE_WITH_SCOPES + self.client.patch.return_value = response + + def test_update_forwards_scope_bridge_fields(self): + """All three fields are forwarded verbatim when passed.""" + allowed = ["openid", "campus.api"] + upstream = {"google": ["https://www.googleapis.com/auth/calendar"]} + client = self.auth.clients["cid123"].update( + allowed_scopes=allowed, + upstream_scopes=upstream, + token_bridge=True, + ) + self.assertEqual( + self.client.patch.call_args.kwargs["json"], + { + "allowed_scopes": allowed, + "upstream_scopes": upstream, + "token_bridge": True, + } + ) + # The mocked response payload is CLIENT_RESOURCE_WITH_SCOPES; + # from_resource must absorb all three fields from it. + self.assertEqual( + client.allowed_scopes, + CLIENT_RESOURCE_WITH_SCOPES["allowed_scopes"] + ) + self.assertEqual( + client.upstream_scopes, + CLIENT_RESOURCE_WITH_SCOPES["upstream_scopes"] + ) + self.assertTrue(client.token_bridge) + + def test_update_forwards_no_token_bridge_false(self): + """--no-token-bridge style updates send token_bridge=False.""" + response = Mock() + response.json.return_value = {**CLIENT_RESOURCE, "token_bridge": False} + self.client.patch.return_value = response + self.auth.clients["cid123"].update(token_bridge=False) + self.assertEqual( + self.client.patch.call_args.kwargs["json"], + {"token_bridge": False} + ) + + def test_update_omits_scope_bridge_fields_when_unset(self): + """Unset scope/bridge fields stay out of the PATCH body.""" + self.auth.clients["cid123"].update(name="new-name") + body = self.client.patch.call_args.kwargs["json"] + self.assertEqual(body, {"name": "new-name"}) + self.assertNotIn("allowed_scopes", body) + self.assertNotIn("upstream_scopes", body) + self.assertNotIn("token_bridge", body) + + def test_update_empty_allowed_scopes_is_sent(self): + """An explicit empty allowlist must not be dropped: fail-closed + A1 means an empty list grants nothing, so clearing is a real + operation the admin may need to send. + """ + response = Mock() + response.json.return_value = {**CLIENT_RESOURCE, "allowed_scopes": []} + self.client.patch.return_value = response + self.auth.clients["cid123"].update(allowed_scopes=[]) + self.assertEqual( + self.client.patch.call_args.kwargs["json"], + {"allowed_scopes": []} + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_model_field_types.py b/tests/unit/test_model_field_types.py index 00a7644..346b6dc 100644 --- a/tests/unit/test_model_field_types.py +++ b/tests/unit/test_model_field_types.py @@ -41,6 +41,9 @@ "description": "CLI client", "is_public": True, "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"], + "allowed_scopes": [], + "upstream_scopes": {}, + "token_bridge": False, "permissions": {}, }