Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion campus_python/auth/v1/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions tests/unit/test_auth_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions tests/unit/test_model_field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
}

Expand Down
Loading