mirror of
https://github.com/ansible/awx.git
synced 2026-02-13 17:24:45 -03:30
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>
254 lines
8.3 KiB
Python
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()
|