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
29 changes: 25 additions & 4 deletions campus_cli/auth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
[
Expand All @@ -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)

Expand Down
32 changes: 18 additions & 14 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <name> --description <descr>` - Create new OAuth client
- `campus auth client get --client-id <client_id>` - Get client details
- `campus auth client delete --client-id <client_id>` - Delete a client
- `campus auth client revoke --client-id <client_id>` - Revoke client access
- `campus auth client update --client-id <client_id> [--name <name>] [--description <descr>]` - Update client metadata
- `campus client list` - List all OAuth clients
- `campus client new --name <name> --description <descr> [--public] [--redirect-uri <uri>]` - Create new OAuth client
- `campus client get --client-id <client_id>` - Get client details
- `campus client delete --client-id <client_id>` - Delete a client
- `campus client revoke --client-id <client_id>` - Generate a new client secret (the only time it is displayed)
- `campus client update --client-id <client_id> [--name <name>] [--description <descr>] [--redirect-uri <uri>]` - Update client metadata or replace redirect URIs

#### 3. Client Access Management
- `campus auth client access get --client-id <client_id>` - Get access permissions
- `campus auth client access grant --client-id <client_id>` - Grant access
- `campus auth client access revoke --client-id <client_id>` - Revoke access
- `campus auth client access update --client-id <client_id>` - Update access
- `campus client access get --client-id <client_id> [--vault <label>]` - Get access permissions
- `campus client access grant --client-id <client_id> --vault <label> --permission <bitflag>` - Grant vault access (1=READ, 2=CREATE, 4=UPDATE, 8=DELETE)
- `campus client access revoke --client-id <client_id> --vault <label> --permission <bitflag>` - Revoke vault access
- `campus client access update --client-id <client_id> --vault <label> --permission <bitflag>` - Set (replace) a vault's access level

#### 4. Vault Management
- `campus auth vault list --vault <label>` - List all entries in a vault
- `campus auth vault get --vault <label>` - Get entire vault
- `campus auth vault get --vault <label> --key <key>` - Get specific key from vault
- `campus auth vault set --vault <label> --key <key> --value <value>` - Set key-value in vault
- `campus vault list --vault <label>` - List all entries in a vault
- `campus vault get --vault <label> [--key <key>]` - Get a specific key, or the whole vault if --key is omitted
- `campus vault set --vault <label> --key <key> --value <value>` - Set key-value in vault
- `campus vault delete --vault <label> --key <key>` - Delete a key from the vault

All `client` and `vault` commands accept `--dry-run`, which prints the
equivalent `campus_python` code without authenticating or calling the API.

### Non-Functional Requirements

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.

74 changes: 74 additions & 0 deletions tests/integration/test_client_update.py
Original file line number Diff line number Diff line change
@@ -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"]
)
20 changes: 20 additions & 0 deletions tests/integration/test_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading