From 7254fe6ecef235e07f9a5e524e50c71ad4485bd4 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 1 Oct 2026 08:39:46 +0800 Subject: [PATCH 1/3] feat: --redirect-uri on client update for in-place URI registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the repeatable --redirect-uri from 'client new' so existing clients can have redirect_uris replaced without recreating the client (rotating client_id/secret) — the operational prerequisite for exact redirect_uri matching at /authorize (campus#651, RFC 6749 §3.1.2.2). Passes redirect_uris through to Clients.update(); validation accepts any of --name/--description/--redirect-uri; the dry-run snippet shows the kwarg only when passed. Help text and PRD state the replace-not- merge semantics: re-pass the full list when adding a URI. Runtime needs campus-api-python#51 (PUT->PATCH) plus a lock pin refresh; tests mock the API layer and pass either way. Refs #15. --- campus_cli/auth/client.py | 29 ++++++++-- docs/PRD.md | 2 +- tests/integration/test_client_update.py | 74 +++++++++++++++++++++++++ tests/integration/test_dry_run.py | 20 +++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_client_update.py diff --git a/campus_cli/auth/client.py b/campus_cli/auth/client.py index 4e9ca9f..0ca5b99 100644 --- a/campus_cli/auth/client.py +++ b/campus_cli/auth/client.py @@ -241,16 +241,28 @@ def client_update( description: str | None = typer.Option( None, "--description", "-d", help="New client description" ), + redirect_uri: list[str] = typer.Option( # noqa: B008 + [], + "--redirect-uri", + help="OAuth redirect URI (repeat for multiple; REPLACES the existing" + " list, so re-pass the full list when adding one)", + ), output_json: bool = typer.Option(False, "--json", help="Output as JSON"), dry_run: bool = dry_run_option(), ) -> None: """ Update an OAuth client. - Updates the name and/or description of an existing client. + Updates the name, description and/or redirect URIs of an existing + client. --redirect-uri replaces the whole redirect URI list: the + authorization endpoint will require an exact match (RFC 6749 + §3.1.2.2), so re-pass every URI the client must keep. """ - if not name and not description: - print_error("At least one of --name or --description must be provided.") + if not name and not description and not redirect_uri: + print_error( + "At least one of --name, --description or --redirect-uri " + "must be provided." + ) raise typer.Exit(1) if dry_run: @@ -259,6 +271,8 @@ def client_update( update_kwargs.append(f"name={name!r}") if description: update_kwargs.append(f"description={description!r}") + if redirect_uri: + update_kwargs.append(f"redirect_uris={list(redirect_uri)!r}") print_python_api( "campus client update", [ @@ -272,7 +286,14 @@ def client_update( try: api = get_api_client() - client = api.auth_clients[client_id].update(name=name, description=description) + update_kwargs: dict = {} + if name: + update_kwargs["name"] = name + if description: + update_kwargs["description"] = description + if redirect_uri: + update_kwargs["redirect_uris"] = list(redirect_uri) + client = api.auth_clients[client_id].update(**update_kwargs) result = _format_client(client) diff --git a/docs/PRD.md b/docs/PRD.md index 1d2e508..b6c96b0 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -16,7 +16,7 @@ A command-line interface tool for interacting with the Campus API, enabling user - `campus auth client get --client-id ` - Get client details - `campus auth client delete --client-id ` - Delete a client - `campus auth client revoke --client-id ` - Revoke client access -- `campus auth client update --client-id [--name ] [--description ]` - Update client metadata +- `campus auth client update --client-id [--name ] [--description ] [--redirect-uri ]` - Update client metadata or replace redirect URIs #### 3. Client Access Management - `campus auth client access get --client-id ` - Get access permissions diff --git a/tests/integration/test_client_update.py b/tests/integration/test_client_update.py new file mode 100644 index 0000000..0d06468 --- /dev/null +++ b/tests/integration/test_client_update.py @@ -0,0 +1,74 @@ +"""Integration tests for `campus client update` field handling (issue #15). + +The command must forward only the fields the admin passed — including +--redirect-uri, which the API layer replaces wholesale — and reject a +call that would update nothing. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from campus_cli.cli import app + +runner = CliRunner() + + +def _updated_client(**overrides): + """A client model as the API layer would return after an update.""" + fields = { + "id": "uid-x", + "name": "New Name", + "description": "Updated description", + "created_at": "2026-09-30T06:31:24+00:00", + "permissions": {"campus.api": 1}, + } + fields.update(overrides) + return SimpleNamespace(**fields) + + +def _invoke_update(args): + """Run `campus client update` against a mocked API client.""" + api = MagicMock() + api.auth_clients["uid-x"].update.return_value = _updated_client() + with patch("campus_cli.auth.client.get_api_client", return_value=api): + result = runner.invoke(app, ["client", "update", *args]) + return result, api + + +def test_update_requires_at_least_one_field(): + """With no field options, the command errors and calls nothing.""" + result, api = _invoke_update(["--client-id", "uid-x"]) + + assert result.exit_code == 1 + assert "At least one of" in result.stderr + api.auth_clients["uid-x"].update.assert_not_called() + + +def test_update_passes_redirect_uris_through(): + """--redirect-uri is forwarded as redirect_uris in given order.""" + result, api = _invoke_update([ + "--client-id", "uid-x", + "--redirect-uri", "https://a/cb", + "--redirect-uri", "http://localhost:8000/cb", + ]) + + assert result.exit_code == 0 + api.auth_clients["uid-x"].update.assert_called_once_with( + redirect_uris=["https://a/cb", "http://localhost:8000/cb"] + ) + + +def test_update_combines_name_and_redirect_uris(): + """Multiple fields are forwarded together; absent ones are omitted.""" + result, api = _invoke_update([ + "--client-id", "uid-x", + "--name", "New Name", + "--redirect-uri", "https://a/cb", + ]) + + assert result.exit_code == 0 + api.auth_clients["uid-x"].update.assert_called_once_with( + name="New Name", redirect_uris=["https://a/cb"] + ) diff --git a/tests/integration/test_dry_run.py b/tests/integration/test_dry_run.py index 1563eb7..f6406ee 100644 --- a/tests/integration/test_dry_run.py +++ b/tests/integration/test_dry_run.py @@ -86,6 +86,26 @@ def test_client_update_dry_run_only_passed_fields(): assert "description=" not in result.stdout +def test_client_update_dry_run_redirect_uris(): + """client update --dry-run passes redirect_uris only when given.""" + result = _invoke_dry_run( + "client", "update", "--client-id", "uid-x", + "--redirect-uri", "https://a/cb", + "--redirect-uri", "http://localhost:8000/cb", + ) + + assert "campus.auth.clients['uid-x'].update(" in result.stdout + assert ( + "redirect_uris=['https://a/cb', 'http://localhost:8000/cb']" + in result.stdout + ) + + plain = _invoke_dry_run( + "client", "update", "--client-id", "uid-x", "--name", "N" + ) + assert "redirect_uris" not in plain.stdout + + def test_client_delete_dry_run_skips_confirmation(): """client delete --dry-run shows the delete call without prompting.""" result = _invoke_dry_run("client", "delete", "--client-id", "uid-x") From 3bba3ca8ab3c7b36dbc7f46106bb3ac13e562a61 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 1 Oct 2026 09:57:28 +0800 Subject: [PATCH 2/3] chore: refresh campus-api-python lock pin d2fba82 -> 7e0b450: picks up the PATCH verb fix for Clients.update() (upstream #51), so 'campus client update --redirect-uri' works at runtime the moment this merges. Kept on this branch rather than main so the bump lands atomically with the feature that needs it. --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index ef3c30b..2158b3e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -163,7 +163,7 @@ flask = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus-api-python.git" reference = "main" -resolved_reference = "d2fba82f2169e5f67a1e16158f3bcba674749c9f" +resolved_reference = "7e0b4505a8be02be5e2a8317ce9d0ca28e2a4a7f" [[package]] name = "campus-suite" From 27205d91bd975f6f75d5f7fea698f934e90e2729 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 1 Oct 2026 10:02:19 +0800 Subject: [PATCH 3/3] docs: refresh PRD command inventory to match the shipped CLI The inventory predated PRs #9-#13: client/vault commands were shown under the non-existent 'campus auth' prefix (real groups are top-level 'campus client' / 'campus vault'); 'client list' and 'vault delete' were missing; 'client new' lacked --public/--redirect-uri; the access commands lacked --vault/--permission; 'client revoke' was described as revoking access when it rotates the client secret. Adds a note on the #13 --dry-run flag. --- docs/PRD.md | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index b6c96b0..1478249 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -3,7 +3,7 @@ ## Project: campus-cli ### Overview -A command-line interface tool for interacting with the Campus API, enabling users to perform authentication and vault operations directly from the shell. +A command-line interface tool for interacting with the Campus API, enabling users to perform authentication, OAuth client management, and vault operations directly from the shell. ### Core Features @@ -12,23 +12,27 @@ A command-line interface tool for interacting with the Campus API, enabling user - `campus auth logout` - Clear stored credentials #### 2. OAuth Client Management -- `campus auth client new --name --description ` - Create new OAuth client -- `campus auth client get --client-id ` - Get client details -- `campus auth client delete --client-id ` - Delete a client -- `campus auth client revoke --client-id ` - Revoke client access -- `campus auth client update --client-id [--name ] [--description ] [--redirect-uri ]` - Update client metadata or replace redirect URIs +- `campus client list` - List all OAuth clients +- `campus client new --name --description [--public] [--redirect-uri ]` - Create new OAuth client +- `campus client get --client-id ` - Get client details +- `campus client delete --client-id ` - Delete a client +- `campus client revoke --client-id ` - Generate a new client secret (the only time it is displayed) +- `campus client update --client-id [--name ] [--description ] [--redirect-uri ]` - Update client metadata or replace redirect URIs #### 3. Client Access Management -- `campus auth client access get --client-id ` - Get access permissions -- `campus auth client access grant --client-id ` - Grant access -- `campus auth client access revoke --client-id ` - Revoke access -- `campus auth client access update --client-id ` - Update access +- `campus client access get --client-id [--vault