Files
awx/awxkit/test/cli/test_options.py
Seth Foster 9700fb01f2 Fix awx CLI modify command for users with object-level permissions (#16276)
The awx CLI derives available fields for the `modify` command from
the list endpoint's POST action schema. Users with object-level
admin permissions (e.g., Project Admin) but no list-level POST
permission see no field flags, making modify unusable despite having
PUT access on the detail endpoint.

Fall back to the detail endpoint's action schema when POST is not
available on the list endpoint, and prefer PUT over POST when
building modify arguments.

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-12 13:26:15 -05:00

254 lines
8.3 KiB
Python

import argparse
import unittest
from io import StringIO
from awxkit.api.pages import Page
from awxkit.cli.options import ResourceOptionsParser
class ResourceOptionsParser(ResourceOptionsParser):
def get_allowed_options(self):
self.allowed_options = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
class NoPostResourceOptionsParser(ResourceOptionsParser):
"""Simulates a user with object-level PUT but no list-level POST."""
detail_put_actions = {}
def get_allowed_options(self):
self.allowed_options = ['GET', 'PUT', 'PATCH', 'DELETE']
# Simulate the logic from the real get_allowed_options that
# falls back to the detail endpoint's PUT schema when POST
# is not available on the list endpoint.
if 'POST' not in self.options and 'PUT' in self.allowed_options:
if self.detail_put_actions:
self.options['PUT'] = self.detail_put_actions
def handle_custom_actions(self):
pass
class OptionsPage(Page):
def options(self):
return self
def endswith(self, v):
return self.endpoint.endswith(v)
def __getitem__(self, k):
return {
'GET': {},
'POST': {},
'PUT': {},
}
class TestOptions(unittest.TestCase):
def setUp(self):
_parser = argparse.ArgumentParser()
self.parser = _parser.add_subparsers(help='action')
def test_list(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {},
}
}
)
ResourceOptionsParser(None, page, 'users', self.parser)
assert 'list' in self.parser.choices
def test_list_filtering(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {'first_name': {'type': 'string'}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('list', 'POST')
assert 'list' in self.parser.choices
out = StringIO()
self.parser.choices['list'].print_help(out)
assert '--first_name TEXT' in out.getvalue()
def test_list_not_filterable(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {'middle_name': {'type': 'string', 'filterable': False}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('list', 'POST')
assert 'list' in self.parser.choices
out = StringIO()
self.parser.choices['list'].print_help(out)
assert '--middle_name' not in out.getvalue()
def test_creation_optional_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {
'first_name': {
'type': 'string',
'help_text': 'Please specify your first name',
}
},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--first_name TEXT Please specify your first name' in out.getvalue()
def test_creation_required_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'username': {'type': 'string', 'help_text': 'Please specify a username', 'required': True}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--username TEXT Please specify a username'
def test_integer_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'max_hosts': {'type': 'integer'}},
}
}
)
options = ResourceOptionsParser(None, page, 'organizations', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--max_hosts INTEGER' in out.getvalue()
def test_boolean_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'diff_mode': {'type': 'boolean'}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--diff_mode BOOLEAN' in out.getvalue()
def test_choices(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {
'verbosity': {
'type': 'integer',
'choices': [
(0, '0 (Normal)'),
(1, '1 (Verbose)'),
(2, '2 (More Verbose)'),
(3, '3 (Debug)'),
(4, '4 (Connection Debug)'),
(5, '5 (WinRM Debug)'),
],
}
},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--verbosity {0,1,2,3,4,5}' in out.getvalue()
def test_actions_with_primary_key(self):
page = OptionsPage.from_json({'actions': {'GET': {}, 'POST': {}}})
ResourceOptionsParser(None, page, 'jobs', self.parser)
for method in ('get', 'modify', 'delete'):
assert method in self.parser.choices
out = StringIO()
self.parser.choices[method].print_help(out)
assert 'positional arguments:\n id' in out.getvalue()
def test_modify_without_list_post(self):
"""User with object-level PUT but no list-level POST can still modify."""
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
}
}
)
NoPostResourceOptionsParser.detail_put_actions = {
'scm_branch': {'type': 'string', 'help_text': 'SCM branch'},
'description': {'type': 'string', 'help_text': 'Description'},
}
options = NoPostResourceOptionsParser(None, page, 'projects', self.parser)
assert 'modify' in self.parser.choices
assert 'create' not in self.parser.choices
options.build_query_arguments('modify', 'PUT')
out = StringIO()
self.parser.choices['modify'].print_help(out)
assert '--scm_branch TEXT' in out.getvalue()
assert '--description TEXT' in out.getvalue()
class TestSettingsOptions(unittest.TestCase):
def setUp(self):
_parser = argparse.ArgumentParser()
self.parser = _parser.add_subparsers(help='action')
def test_list(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {},
'PUT': {},
}
}
)
page.endpoint = '/settings/all/'
ResourceOptionsParser(None, page, 'settings', self.parser)
assert 'list' in self.parser.choices
assert 'modify' in self.parser.choices
out = StringIO()
self.parser.choices['modify'].print_help(out)
assert 'modify [-h] key value' in out.getvalue()