[AAP-78694] Add CleanTextMixin to Controller Serializers (#16635)

* AAP-78694 - Add CleanTextMixin to AWX resource serializers

Applies django-ansible-base's CleanTextMixin to the AWX serializers
listed in AAP-78694: Organization, User, Team, Credential(+Create),
CredentialType, Project, JobTemplate, WorkflowJobTemplate, Inventory,
Host, Group, InventorySource, InstanceGroup, Instance,
ExecutionEnvironment, NotificationTemplate, Schedule, AdHocCommand,
WorkflowJobTemplateNode, WorkflowJobTemplateNodeCreateApproval,
JobLaunch, and WorkflowJobLaunch.

Adds a local PromptFieldCleanTextMixin for serializers whose model
derives from LaunchTimeConfigBase, where scm_branch/limit/job_tags/
skip_tags are NullablePromptPseudoField descriptors invisible to the
mixin's normal model-field auto-discovery.

Per-serializer excluded_fields/name_fields overrides are documented
inline where a field's existing behavior would otherwise conflict
with Tier 1/2 validation (Jinja2-templated fields, secret fields,
and existing colon-containing name/hostname syntax).

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Cover CleanTextMixin gaps in Copy, Bulk, and Job Detail paths

Three write paths were found to bypass the Tier 1/2 validation applied
elsewhere for the same resources:

- CopySerializer (used by the Copy button/endpoint for EE, Project,
  Credential, JobTemplate, WorkflowJobTemplate, NotificationTemplate)
  accepted a user-supplied name with zero validation. Adds a new
  PlainSerializerCleanTextMixin for plain serializers.Serializer
  subclasses with no backing model.

- BulkJobNodeSerializer's validate() intentionally skips
  LaunchConfigurationBaseSerializer.validate() (it assumes
  unified_job_template/inventory/execution_environment are model
  instances, but bulk nodes pass raw PKs to avoid a per-node DB
  lookup), which meant it also skipped CleanTextMixin. Adds
  PromptFieldCleanTextMixin._run_clean_text_validation() so the
  Tier 1/2 checks still run despite that skip. BulkJobLaunchSerializer
  and WorkflowJobNodeSerializer are also covered.

- JobSerializer (backing the narrow JobDetail PUT/PATCH window while
  a job's status is "new") had no coverage at all.

Confirmed live against a running controller that a Copy with a
malicious name previously succeeded (201) where the equivalent PATCH
on the same resource was already rejected (400); both are rejected
consistently after this change.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Fix NotificationTemplateSerializer validate() bypass

validate() returned early without calling super().validate() whenever
notification_configuration was absent from the request (e.g. a PATCH
that only changes name/description), which skipped CleanTextMixin
entirely on that path. Same bug class as the BulkJobNodeSerializer
MRO-skip already fixed, caught by code review.

Confirmed live: a PATCH with only a malicious name (no
notification_configuration) previously succeeded; now rejected with
400, while a legitimate name-only PATCH still succeeds.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Add CleanTextMixin to the previously out-of-scope API-only serializers

LabelSerializer, CredentialInputSourceSerializer, and
WorkflowApprovalTemplateSerializer were originally scoped out ("no UI
exposure") but are now in scope per updated ticket guidance.

WorkflowApprovalTemplateSerializer backs the update path for the same
model WorkflowJobTemplateNodeCreateApprovalSerializer already protects
on create -- without this, an approval name rejected at creation could
be rewritten to anything via PATCH.

Confirmed live: Label create and WorkflowApprovalTemplate update both
reject a malicious name (400) and accept a valid one.
CredentialInputSourceSerializer verified at the code level only (plain
CharField input_field_name, no custom validators; metadata is a
JSONSchemaField holding vault paths/keys, not secret values) --
setting up a live external-credential-type chain in this environment
wasn't done.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Correct HostSerializer name_fields demotion comment

The comment claimed the Tier 1 -> Tier 2 demotion was needed for
"hostname:port" syntax, but that colon is stripped from attrs['name']
in validate() before it reaches CleanTextMixin -- that case never
needed the demotion. What actually needs it: names with any colon
count other than exactly 1, which _get_host_port_from_name passes
through untouched -- most notably IPv6 addresses, which its own
comment says it explicitly does not parse. Caught by code review.

Confirmed live: an IPv6-style host name (2 colons) is accepted
unmodified (201).

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Validate non-secret notification_configuration keys

Replaced the blanket notification_configuration exclusion with a
precise excluded_json_keys covering only the actual secret fields.
Unlike Credential.inputs (admin-definable custom types with arbitrary
secret fields), notification_type is a fixed models.CharField(choices)
-- every backend class ships in awx/main/notifications/ and is known
at code-authoring time, so the secret key set can be a static list
rather than resolved per-instance.

Enumerated every init_parameters entry with type "password" across
all 8 backends (email/irc/webhook password, slack/pagerduty token,
twilio account_token, grafana grafana_key, awssns
aws_secret_access_key/aws_session_token) and confirmed none of those
names are used for a non-secret value in a different backend.

Previously-unvalidated, UI-exposed fields like webhook url and slack
channels are now covered. Confirmed live: a javascript: webhook url
and a <script>-tagged slack channel are now rejected (400), while a
weird-looking token/password value is still accepted unchanged.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Exclude extra_data on launch-config serializers

extra_data (WorkflowJobTemplateNode/WorkflowJobNode/Schedule) plays
the same role as extra_vars elsewhere and is submitted in the same
two formats -- a dict, or a raw YAML/JSON string (parse_yaml_or_json
handles both). Its model field is JSONBlob, whose get_internal_type()
reports "TextField" for legacy DB-migration reasons, so
CleanTextMixin silently skipped dict payloads (not a str) while
string payloads hit Tier 2's template-injection blocklist and
incorrectly rejected legitimate Jinja variable references -- a real
regression once enforcement is on, caught by code review.

First attempt put the exclusion on the shared
LaunchConfigurationBaseSerializer base and silently did nothing:
CleanTextMixin's own excluded_fields = frozenset() default sits
earlier in the MRO than that base class for every subclass (mixin is
first in the bases tuple), so the base class's value was always
shadowed. Verified with a standalone MRO test before settling on
setting excluded_fields directly on each leaf serializer
(WorkflowJobTemplateNodeSerializer, WorkflowJobNodeSerializer,
ScheduleSerializer), matching every other exclusion in this file.

Confirmed live on both WorkflowJobTemplateNodeSerializer and
ScheduleSerializer: a YAML-string extra_data with a Jinja reference
is accepted (previously 400), while other Tier 1/2 fields on the same
serializers are still rejected as expected.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* AAP-78694 - Validate non-secret Credential.inputs keys dynamically

Unlike NotificationTemplate.notification_configuration (fixed set of
backend classes, static excluded_json_keys works), CredentialType is
admin-definable via a real "Create a new custom credential type"
endpoint, so the set of secret field names in Credential.inputs can't
be known at class-definition time -- it varies per credential's own
type, via that type's own inputs schema ("secret": true flags,
exposed as CredentialType.secret_fields).

validate() resolves the credential_type from attrs (create) or the
existing instance (update) and sets excluded_json_keys as an instance
attribute before chaining into CleanTextMixin.validate(). Setting an
instance attribute rather than a class attribute sidesteps the MRO
trap found while fixing extra_data on launch-config serializers:
CleanTextMixin's own excluded_fields/excluded_json_keys class-level
defaults sit earlier in the MRO than any shared base class further
down the chain, silently shadowing it -- an instance attribute always
wins regardless of MRO position, and correctly threads through
CredentialSerializerCreate and its User/Team/Organization subclasses
for free, since each of their validate() overrides chains through
super() into this one.

Falls back to excluding the whole inputs blob (prior, safe behavior)
if credential_type can't be resolved.

Confirmed live: malicious username (non-secret, including via PATCH
with no credential_type in the payload) is rejected; a dangerous-
looking password value and a real unencrypted SSH private key
(secret fields) are both still accepted unchanged.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* Increase test coverage

* Cover remaining serializer branches for Sonar gate

Line coverage is already over 90%; Sonar's quality gate uses combined
line+branch coverage. These unit tests exercise previously untested
methods so the PR can clear the 90% new-code threshold.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* style(api): format uncovered serializer tests for ruff

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>
Assisted-by: Cursor Grok 4.6 (SpaceXAI / Cursor)
Co-authored-by: Cursor <cursoragent@cursor.com>

* test(api): set Mock.name as an attribute, not a constructor kwarg

unittest.mock.Mock(name=...) only sets the mock's repr name, so
getattr(obj, 'name') returned another Mock and four coverage tests
failed in api-test.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>
Assisted-by: Cursor Grok 4.6 (SpaceXAI / Cursor)
Co-authored-by: Cursor <cursoragent@cursor.com>

* test(api): keep a single call inside pytest.raises blocks

Sonar python:S5918 flags exception tests that construct mocks or
serializers inside the raises context.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>
Assisted-by: Cursor Grok 4.6 (SpaceXAI / Cursor)
Co-authored-by: Cursor <cursoragent@cursor.com>

* Add more test coverage

---------

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Pratyush Bhandari
2026-09-10 16:24:01 +02:00
committed by GitHub
co-authored by Cursor
parent fcd7003510
commit 9fdfc95d37
9 changed files with 960 additions and 31 deletions
+269 -31
View File
@@ -10,6 +10,7 @@ import yaml
import urllib.parse import urllib.parse
from collections import Counter, OrderedDict from collections import Counter, OrderedDict
from datetime import timedelta from datetime import timedelta
from types import MappingProxyType
from uuid import uuid4 from uuid import uuid4
# Jinja # Jinja
@@ -42,7 +43,10 @@ from rest_framework.utils.serializer_helpers import ReturnList
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
# django-ansible-base # django-ansible-base
from ansible_base.lib.serializers.mixins import CleanTextMixin
from ansible_base.lib.utils.models import get_type_for_model from ansible_base.lib.utils.models import get_type_for_model
from ansible_base.lib.utils.settings import get_setting
from ansible_base.lib.utils.validation import DEFAULT_NAME_FIELDS
from ansible_base.rbac.models import RoleEvaluation, ObjectRole from ansible_base.rbac.models import RoleEvaluation, ObjectRole
from ansible_base.rbac import permission_registry from ansible_base.rbac import permission_registry
@@ -210,16 +214,52 @@ def reverse_gfk(content_object, request):
return {camelcase_to_underscore(content_object.__class__.__name__): content_object.get_absolute_url(request=request)} return {camelcase_to_underscore(content_object.__class__.__name__): content_object.get_absolute_url(request=request)}
class CopySerializer(serializers.Serializer): class PlainSerializerCleanTextMixin(CleanTextMixin):
"""CleanTextMixin for plain `serializers.Serializer` subclasses with no
backing Django model (AAP-78694). Overrides ONLY field discovery -- treats
the serializer's own declared CharFields as the text fields to validate.
Everything else (Tier 1/2 dispatch, exclusions, audit logging, the
enforcement gate) is reused unchanged from CleanTextMixin.
Known limitation: unlike the model-field path, this can't replicate the
get_internal_type() exclusion of SlugField/IPAddressField-style fields
(DRF fields have no equivalent signal) -- not an issue for any serializer
using this mixin today, since none declare such fields.
"""
def _classify_fields(self, model):
text_fields = [name for name, field in self.fields.items() if isinstance(field, serializers.CharField)]
return text_fields, []
class _CopySerializerFakeModel:
"""Stand-in for Meta.model on CopySerializer, a plain serializers.Serializer
with no backing model of its own (it's reused across many resource types via
CopyAPIView subclasses). Only satisfies the `_meta.app_label`/`object_name`
attributes CleanTextMixin's audit-log message reads (AAP-78694) -- never
introspected for real fields, since PlainSerializerCleanTextMixin overrides
field discovery."""
_meta = type('Meta', (), {'app_label': 'main', 'object_name': 'CopySerializer'})
class CopySerializer(PlainSerializerCleanTextMixin, serializers.Serializer):
# The submitted name becomes the new object's name (see
# CopyAPIView.post -> copy_model_obj in awx/api/generics.py), so it needs
# the same Tier 1 protection as the 'name' field on the resource being
# copied (AAP-78694) -- without this, Copy bypassed validation entirely.
name = serializers.CharField() name = serializers.CharField()
class Meta:
model = _CopySerializerFakeModel
def validate(self, attrs): def validate(self, attrs):
name = attrs.get('name') name = attrs.get('name')
view = self.context.get('view', None) view = self.context.get('view', None)
obj = view.get_object() obj = view.get_object()
if name == obj.name: if name == obj.name:
raise serializers.ValidationError(_('The original object is already named {}, a copy from it cannot have the same name.'.format(name))) raise serializers.ValidationError(_('The original object is already named {}, a copy from it cannot have the same name.'.format(name)))
return attrs return super().validate(attrs)
class BaseSerializerMetaclass(serializers.SerializerMetaclass): class BaseSerializerMetaclass(serializers.SerializerMetaclass):
@@ -727,6 +767,52 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl
return False return False
class PromptFieldCleanTextMixin(CleanTextMixin):
"""
CleanTextMixin for serializers whose Meta.model derives from
LaunchTimeConfigBase (WorkflowJobTemplate, WorkflowJobTemplateNode,
WorkflowJobNode, Schedule). Those models store scm_branch/limit/job_tags/
skip_tags as NullablePromptPseudoField descriptors backed by the
char_prompts JSONField, not real django.db.models.Field instances, so
CleanTextMixin's model._meta.get_fields() auto-discovery can never see
them (AAP-78694). This adds them back in explicitly as Tier 2 free-text
fields. They are always declared as plain serializer CharFields (see
LaunchConfigurationBaseSerializer below), so self.fields has them
whenever applicable, and grandfathering still works because the
pseudo-field descriptor's __get__ correctly returns the stored
char_prompts value via getattr(self.instance, name).
"""
prompt_pseudo_fields = frozenset({'scm_branch', 'limit', 'job_tags', 'skip_tags'})
def _classify_fields(self, model):
text_fields, json_fields = super()._classify_fields(model)
extra = [f for f in self.prompt_pseudo_fields if f in self.fields and f not in text_fields]
return list(text_fields) + extra, json_fields
def _run_clean_text_validation(self, attrs):
"""Run Tier 1/2 validation and raise on failure, without chaining into
the next class in the MRO's validate(). Needed by BulkJobNodeSerializer,
whose own validate() intentionally skips LaunchConfigurationBaseSerializer.validate()
(that method assumes unified_job_template/inventory/execution_environment
are model instances, but BulkJobNodeSerializer declares them as raw PKs
to avoid a DB lookup per node) via `super(LaunchConfigurationBaseSerializer, self)`.
A plain `self.validate(attrs)` call there would still run this mixin's
own `super().validate(attrs)` chain straight into the skipped class, since
Python's zero-arg super() resolves against the instance's full MRO
regardless of how the method was invoked -- so the Tier 1/2 checks are
exposed here as a standalone step instead.
"""
model = self.Meta.model
text_fields, json_fields = self._classify_fields(model)
errors = {}
self._validate_text_fields(text_fields, attrs, errors)
self._validate_json_fields(json_fields, attrs, errors)
if errors and get_setting('ENHANCED_INPUT_VALIDATION_ENABLED', False):
raise serializers.ValidationError(errors)
return attrs
class EmptySerializer(serializers.Serializer): class EmptySerializer(serializers.Serializer):
pass pass
@@ -1031,7 +1117,18 @@ class UnifiedJobStdoutSerializer(UnifiedJobSerializer):
return super(UnifiedJobStdoutSerializer, self).get_types() return super(UnifiedJobStdoutSerializer, self).get_types()
class UserSerializer(BaseSerializer): class UserSerializer(CleanTextMixin, BaseSerializer):
# password is excluded because it is a raw secret at validation time (pre-hash);
# running the Tier 2 blocklist against it would both leak nothing useful and
# reject otherwise-valid passwords containing blocklisted characters.
excluded_fields = frozenset({'password'})
# username is deliberately left in Tier 1 (default name_fields) even though
# Django's stock UnicodeUsernameValidator (^[\w.@+-]+$) allows '+', which
# Tier 1's allowlist does not. This is an intentional hardening decision
# (ANSTRAT-1756): new/changed usernames containing '+' will be rejected once
# enforcement is enabled. Existing '+' usernames are grandfathered until edited.
password = serializers.CharField(required=False, default='', allow_blank=True, help_text=_('Field used to change the password.')) password = serializers.CharField(required=False, default='', allow_blank=True, help_text=_('Field used to change the password.'))
is_system_auditor = serializers.BooleanField(default=False) is_system_auditor = serializers.BooleanField(default=False)
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
@@ -1192,7 +1289,7 @@ class UserActivityStreamSerializer(UserSerializer):
fields = ('*', '-is_system_auditor') fields = ('*', '-is_system_auditor')
class OrganizationSerializer(BaseSerializer, OpaQueryPathMixin): class OrganizationSerializer(CleanTextMixin, BaseSerializer, OpaQueryPathMixin):
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
class Meta: class Meta:
@@ -1314,7 +1411,7 @@ class ProjectOptionsSerializer(BaseSerializer):
return super(ProjectOptionsSerializer, self).validate(attrs) return super(ProjectOptionsSerializer, self).validate(attrs)
class ExecutionEnvironmentSerializer(BaseSerializer): class ExecutionEnvironmentSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete', 'copy'] show_capabilities = ['edit', 'delete', 'copy']
managed = serializers.ReadOnlyField() managed = serializers.ReadOnlyField()
@@ -1349,7 +1446,7 @@ class ExecutionEnvironmentSerializer(BaseSerializer):
return super(ExecutionEnvironmentSerializer, self).validate(attrs) return super(ExecutionEnvironmentSerializer, self).validate(attrs)
class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): class ProjectSerializer(CleanTextMixin, UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True) status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True)
last_update_failed = serializers.BooleanField(read_only=True) last_update_failed = serializers.BooleanField(read_only=True)
last_updated = serializers.DateTimeField(read_only=True) last_updated = serializers.DateTimeField(read_only=True)
@@ -1551,10 +1648,14 @@ class LabelsListMixin(object):
return res return res
class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables, OpaQueryPathMixin): class InventorySerializer(CleanTextMixin, LabelsListMixin, BaseSerializerWithVariables, OpaQueryPathMixin):
show_capabilities = ['edit', 'delete', 'adhoc', 'copy'] show_capabilities = ['edit', 'delete', 'adhoc', 'copy']
capabilities_prefetch = [{'edit': 'change'}, {'adhoc': 'adhoc'}, {'copy': 'organization.add_inventory'}] capabilities_prefetch = [{'edit': 'change'}, {'adhoc': 'adhoc'}, {'copy': 'organization.add_inventory'}]
# variables is raw YAML/JSON that legitimately contains Jinja2 syntax,
# same conflict class as extra_vars.
excluded_fields = frozenset({'variables'})
class Meta: class Meta:
model = Inventory model = Inventory
fields = ( fields = (
@@ -1795,10 +1896,21 @@ class InventoryScriptSerializer(InventorySerializer):
fields = () fields = ()
class HostSerializer(BaseSerializerWithVariables): class HostSerializer(CleanTextMixin, BaseSerializerWithVariables):
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
capabilities_prefetch = [{'edit': 'inventory.change'}] capabilities_prefetch = [{'edit': 'inventory.change'}]
# variables is raw YAML/JSON that legitimately contains Jinja2 syntax.
excluded_fields = frozenset({'variables'})
# NOT for the single "host:port" syntax (see _get_host_port_from_name
# below) -- that colon is stripped from attrs['name'] in validate()
# before it ever reaches Tier 1. This demotion is for names
# _get_host_port_from_name passes through untouched: any colon count
# other than exactly 1, most notably IPv6 addresses, which its own
# comment says it explicitly does not parse ("except IPv6 for now").
# Tier 1's allowlist has no colon, so those would otherwise be rejected.
name_fields = DEFAULT_NAME_FIELDS - {'name'}
has_active_failures = serializers.SerializerMethodField() has_active_failures = serializers.SerializerMethodField()
has_inventory_sources = serializers.SerializerMethodField() has_inventory_sources = serializers.SerializerMethodField()
last_job = serializers.SerializerMethodField() last_job = serializers.SerializerMethodField()
@@ -1979,10 +2091,13 @@ class AnsibleFactsSerializer(BaseSerializer):
return obj.ansible_facts return obj.ansible_facts
class GroupSerializer(BaseSerializerWithVariables): class GroupSerializer(CleanTextMixin, BaseSerializerWithVariables):
show_capabilities = ['copy', 'edit', 'delete'] show_capabilities = ['copy', 'edit', 'delete']
capabilities_prefetch = [{'edit': 'inventory.change'}, {'adhoc': 'inventory.adhoc'}] capabilities_prefetch = [{'edit': 'inventory.change'}, {'adhoc': 'inventory.adhoc'}]
# variables is raw YAML/JSON that legitimately contains Jinja2 syntax.
excluded_fields = frozenset({'variables'})
class Meta: class Meta:
model = Group model = Group
fields = ('*', 'inventory', 'variables') fields = ('*', 'inventory', 'variables')
@@ -2383,13 +2498,16 @@ class InventorySourceOptionsSerializer(BaseSerializer):
return summary_fields return summary_fields
class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOptionsSerializer): class InventorySourceSerializer(CleanTextMixin, UnifiedJobTemplateSerializer, InventorySourceOptionsSerializer):
status = serializers.ChoiceField(choices=InventorySource.INVENTORY_SOURCE_STATUS_CHOICES, read_only=True) status = serializers.ChoiceField(choices=InventorySource.INVENTORY_SOURCE_STATUS_CHOICES, read_only=True)
last_update_failed = serializers.BooleanField(read_only=True) last_update_failed = serializers.BooleanField(read_only=True)
last_updated = serializers.DateTimeField(read_only=True) last_updated = serializers.DateTimeField(read_only=True)
show_capabilities = ['start', 'schedule', 'edit', 'delete'] show_capabilities = ['start', 'schedule', 'edit', 'delete']
capabilities_prefetch = [{'edit': 'inventory.change'}, {'start': 'inventory.update'}] capabilities_prefetch = [{'edit': 'inventory.change'}, {'start': 'inventory.update'}]
# source_vars is raw YAML/JSON that legitimately contains Jinja2 syntax.
excluded_fields = frozenset({'source_vars'})
class Meta: class Meta:
model = InventorySource model = InventorySource
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project') + ( fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project') + (
@@ -2651,7 +2769,7 @@ class InventoryUpdateCancelSerializer(InventoryUpdateSerializer):
fields = ('can_cancel',) fields = ('can_cancel',)
class TeamSerializer(BaseSerializer): class TeamSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
class Meta: class Meta:
@@ -2934,10 +3052,15 @@ class ResourceAccessListElementSerializer(UserSerializer):
return ret return ret
class CredentialTypeSerializer(BaseSerializer): class CredentialTypeSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
managed = serializers.ReadOnlyField() managed = serializers.ReadOnlyField()
# injectors is Jinja2-templated by design (e.g. "{{ api_token }}" in env/file
# injection definitions) -- Tier 2's injection blocklist would break every
# custom credential type that defines injectors.
excluded_fields = frozenset({'injectors'})
class Meta: class Meta:
model = CredentialType model = CredentialType
fields = ('*', 'kind', 'namespace', 'name', 'managed', 'inputs', 'injectors') fields = ('*', 'kind', 'namespace', 'name', 'managed', 'inputs', 'injectors')
@@ -3005,11 +3128,19 @@ class CredentialTypeSerializer(BaseSerializer):
return fields return fields
class CredentialSerializer(BaseSerializer): class CredentialSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete', 'copy', 'use'] show_capabilities = ['edit', 'delete', 'copy', 'use']
capabilities_prefetch = [{'edit': 'change'}, {'use': 'use'}] capabilities_prefetch = [{'edit': 'change'}, {'use': 'use'}]
managed = serializers.ReadOnlyField() managed = serializers.ReadOnlyField()
# inputs is a JSONField whose schema is credential-type-dependent, including
# admin-defined custom types. Secret keys cannot be listed at class level
# (unlike NotificationTemplateSerializer.notification_configuration).
# validate() below sets instance excluded_json_keys from
# credential_type.secret_fields (the schema's "secret": true flags) so
# non-secret strings (username, host, ...) still get Tier 2. If the type
# cannot be resolved, the whole blob is skipped (fail closed).
class Meta: class Meta:
model = Credential model = Credential
fields = ('*', 'organization', 'credential_type', 'managed', 'inputs', 'kind', 'cloud', 'kubernetes') fields = ('*', 'organization', 'credential_type', 'managed', 'inputs', 'kind', 'cloud', 'kubernetes')
@@ -3081,6 +3212,16 @@ class CredentialSerializer(BaseSerializer):
def validate(self, attrs): def validate(self, attrs):
if self.instance and self.instance.managed: if self.instance and self.instance.managed:
raise PermissionDenied(detail=_("Modifications not allowed for managed credentials")) raise PermissionDenied(detail=_("Modifications not allowed for managed credentials"))
# Shadow the class-level MappingProxyType on this instance only -- DRF
# constructs a new serializer per request, so this does not leak.
# Must run before super().validate() so CleanTextMixin sees it.
cred_type = attrs.get('credential_type') or getattr(self.instance, 'credential_type', None)
if cred_type is not None:
self.excluded_json_keys = MappingProxyType({'inputs': frozenset(cred_type.secret_fields)})
else:
self.excluded_fields = frozenset(self.excluded_fields) | {'inputs'}
return super(CredentialSerializer, self).validate(attrs) return super(CredentialSerializer, self).validate(attrs)
def get_validation_exclusions(self, obj=None): def get_validation_exclusions(self, obj=None):
@@ -3183,7 +3324,7 @@ class CredentialSerializerCreate(CredentialSerializer):
return credential return credential
class CredentialInputSourceSerializer(BaseSerializer): class CredentialInputSourceSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['delete'] show_capabilities = ['delete']
class Meta: class Meta:
@@ -3346,10 +3487,14 @@ class JobTemplateMixin(object):
return super().validate(attrs) return super().validate(attrs)
class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobOptionsSerializer): class JobTemplateSerializer(CleanTextMixin, JobTemplateMixin, UnifiedJobTemplateSerializer, JobOptionsSerializer):
show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete'] show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete']
capabilities_prefetch = [{'edit': 'change'}, {'start': 'execute'}, {'copy': ['project.use', 'inventory.use']}] capabilities_prefetch = [{'edit': 'change'}, {'start': 'execute'}, {'copy': ['project.use', 'inventory.use']}]
# extra_vars is raw YAML/JSON that legitimately contains Jinja2 syntax
# ("{{ var }}"), which Tier 2's injection blocklist would reject.
excluded_fields = frozenset({'extra_vars'})
status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False) status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False)
class Meta: class Meta:
@@ -3467,7 +3612,13 @@ class JobTemplateWithSpecSerializer(JobTemplateSerializer):
fields = ('*', 'survey_spec') fields = ('*', 'survey_spec')
class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): class JobSerializer(CleanTextMixin, UnifiedJobSerializer, JobOptionsSerializer):
# extra_vars is raw YAML/JSON that legitimately contains Jinja2 syntax
# ("{{ var }}"), same conflict class as JobTemplateSerializer.extra_vars
# (AAP-78694). JobDetail allows PUT/PATCH while status is "new" (see
# JobDetail.update), which is the real write path this protects.
excluded_fields = frozenset({'extra_vars'})
passwords_needed_to_start = serializers.ReadOnlyField() passwords_needed_to_start = serializers.ReadOnlyField()
artifacts = serializers.SerializerMethodField() artifacts = serializers.SerializerMethodField()
@@ -3677,7 +3828,12 @@ class JobCreateScheduleSerializer(LabelsListMixin, BaseSerializer):
return {'all': _('Unknown, job may have been run before launch configurations were saved.')} return {'all': _('Unknown, job may have been run before launch configurations were saved.')}
class AdHocCommandSerializer(UnifiedJobSerializer): class AdHocCommandSerializer(CleanTextMixin, UnifiedJobSerializer):
# extra_vars/module_args are raw YAML/JSON or ad hoc arguments that
# legitimately contain Jinja2 syntax ("{{ var }}"), which Tier 2's
# injection blocklist would reject.
excluded_fields = frozenset({'extra_vars', 'module_args'})
class Meta: class Meta:
model = AdHocCommand model = AdHocCommand
fields = ( fields = (
@@ -3835,9 +3991,13 @@ class SystemJobCancelSerializer(SystemJobSerializer):
fields = ('can_cancel',) fields = ('can_cancel',)
class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJobTemplateSerializer): class WorkflowJobTemplateSerializer(PromptFieldCleanTextMixin, JobTemplateMixin, LabelsListMixin, UnifiedJobTemplateSerializer):
show_capabilities = ['start', 'schedule', 'edit', 'copy', 'delete'] show_capabilities = ['start', 'schedule', 'edit', 'copy', 'delete']
capabilities_prefetch = [{'edit': 'change'}, {'start': 'execute'}, {'copy': 'organization.add_workflowjobtemplate'}] capabilities_prefetch = [{'edit': 'change'}, {'start': 'execute'}, {'copy': 'organization.add_workflowjobtemplate'}]
# extra_vars is raw YAML/JSON that legitimately contains Jinja2 syntax.
excluded_fields = frozenset({'extra_vars'})
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
@@ -4052,7 +4212,7 @@ class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListS
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out') fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out')
class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): class WorkflowApprovalTemplateSerializer(CleanTextMixin, UnifiedJobTemplateSerializer):
class Meta: class Meta:
model = WorkflowApprovalTemplate model = WorkflowApprovalTemplate
fields = ('*', 'timeout', 'name') fields = ('*', 'timeout', 'name')
@@ -4234,7 +4394,22 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
return attrs return attrs
class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): class WorkflowJobTemplateNodeSerializer(PromptFieldCleanTextMixin, LaunchConfigurationBaseSerializer):
# extra_data plays the same role as extra_vars elsewhere (arbitrary
# launch-time variables) and is submitted in the same two formats: a
# dict, or a raw YAML/JSON string (see parse_yaml_or_json). Its model
# field is JSONBlob, whose get_internal_type() reports "TextField" for
# legacy DB-migration reasons (awx/main/fields.py); since CleanTextMixin
# classifies fields via get_internal_type(), a dict payload is silently
# skipped (not a str) while a string payload hits Tier 2's
# template-injection blocklist and incorrectly rejects legitimate Jinja
# variable references -- same conflict class as extra_vars (AAP-78694).
# NOTE: this must be set directly on this class, not on
# LaunchConfigurationBaseSerializer -- CleanTextMixin's own
# excluded_fields default sits earlier in the MRO than that shared base,
# so a base-class override there is silently shadowed and never applies.
excluded_fields = frozenset({'extra_data'})
success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
@@ -4287,7 +4462,12 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer):
return summary_fields return summary_fields
class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): class WorkflowJobNodeSerializer(PromptFieldCleanTextMixin, LaunchConfigurationBaseSerializer):
# See WorkflowJobTemplateNodeSerializer.excluded_fields above -- same
# reasoning, and same requirement that this live directly on this class
# rather than on the shared LaunchConfigurationBaseSerializer base.
excluded_fields = frozenset({'extra_data'})
success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
@@ -4357,7 +4537,7 @@ class WorkflowJobTemplateNodeDetailSerializer(WorkflowJobTemplateNodeSerializer)
return field_class, field_kwargs return field_class, field_kwargs
class WorkflowJobTemplateNodeCreateApprovalSerializer(BaseSerializer): class WorkflowJobTemplateNodeCreateApprovalSerializer(CleanTextMixin, BaseSerializer):
class Meta: class Meta:
model = WorkflowApprovalTemplate model = WorkflowApprovalTemplate
fields = ('timeout', 'name', 'description') fields = ('timeout', 'name', 'description')
@@ -4579,7 +4759,13 @@ class SystemJobEventSerializer(AdHocCommandEventSerializer):
return res return res
class JobLaunchSerializer(BaseSerializer): class JobLaunchSerializer(CleanTextMixin, BaseSerializer):
# extra_vars is declared as a JSONField below, so it's already a parsed
# dict/list (not a str) by the time CleanTextMixin.validate() runs, which
# makes this a no-op today -- excluded anyway for clarity/future-proofing,
# consistent with JobTemplateSerializer's extra_vars exclusion.
excluded_fields = frozenset({'extra_vars'})
# Representational fields # Representational fields
passwords_needed_to_start = serializers.ReadOnlyField() passwords_needed_to_start = serializers.ReadOnlyField()
can_start_without_user_input = serializers.BooleanField(read_only=True) can_start_without_user_input = serializers.BooleanField(read_only=True)
@@ -4795,7 +4981,13 @@ class JobLaunchSerializer(BaseSerializer):
return accepted return accepted
class WorkflowJobLaunchSerializer(BaseSerializer): class WorkflowJobLaunchSerializer(PromptFieldCleanTextMixin, BaseSerializer):
# extra_vars is declared as a VerbatimField below, so it's already a
# parsed dict/list (not a str) by the time CleanTextMixin.validate() runs,
# which makes this a no-op today -- excluded anyway for clarity/future-
# proofing, consistent with WorkflowJobTemplateSerializer's exclusion.
excluded_fields = frozenset({'extra_vars'})
can_start_without_user_input = serializers.BooleanField(read_only=True) can_start_without_user_input = serializers.BooleanField(read_only=True)
defaults = serializers.SerializerMethodField() defaults = serializers.SerializerMethodField()
variables_needed_to_start = serializers.ReadOnlyField() variables_needed_to_start = serializers.ReadOnlyField()
@@ -4913,6 +5105,10 @@ class BulkJobNodeSerializer(WorkflowJobNodeSerializer):
fields = ('*', 'credentials', 'labels', 'instance_groups') # m2m fields are not canonical for WJ nodes fields = ('*', 'credentials', 'labels', 'instance_groups') # m2m fields are not canonical for WJ nodes
def validate(self, attrs): def validate(self, attrs):
# Deliberately skips LaunchConfigurationBaseSerializer.validate() (see
# its comment above) -- run Tier 1/2 validation explicitly first
# since that skip would otherwise bypass it too (AAP-78694).
attrs = self._run_clean_text_validation(attrs)
return super(LaunchConfigurationBaseSerializer, self).validate(attrs) return super(LaunchConfigurationBaseSerializer, self).validate(attrs)
def get_validation_exclusions(self, obj=None): def get_validation_exclusions(self, obj=None):
@@ -4921,7 +5117,15 @@ class BulkJobNodeSerializer(WorkflowJobNodeSerializer):
return ret return ret
class BulkJobLaunchSerializer(serializers.Serializer): class BulkJobLaunchSerializer(PromptFieldCleanTextMixin, serializers.Serializer):
# extra_vars is raw YAML/JSON that legitimately contains Jinja2 syntax
# ("{{ var }}"); by the time this mixin's validate() runs it has already
# been converted from a dict to a JSON string by validate() below
# (unlike JobLaunchSerializer/WorkflowJobLaunchSerializer's extra_vars,
# which stay parsed), so this exclusion is load-bearing here, not
# defensive (AAP-78694).
excluded_fields = frozenset({'extra_vars'})
name = serializers.CharField(default='Bulk Job Launch', max_length=512, write_only=True, required=False, allow_blank=True) # limited by max name of jobs name = serializers.CharField(default='Bulk Job Launch', max_length=512, write_only=True, required=False, allow_blank=True) # limited by max name of jobs
jobs = BulkJobNodeSerializer( jobs = BulkJobNodeSerializer(
many=True, many=True,
@@ -5159,10 +5363,29 @@ class BulkJobLaunchSerializer(serializers.Serializer):
return objectified_jobs return objectified_jobs
class NotificationTemplateSerializer(BaseSerializer): class NotificationTemplateSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete', 'copy'] show_capabilities = ['edit', 'delete', 'copy']
capabilities_prefetch = [{'copy': 'organization.add_notificationtemplate'}] capabilities_prefetch = [{'copy': 'organization.add_notificationtemplate'}]
# messages is user-authored Jinja2 template text ("{{ job.id }}" etc.),
# already validated by its own sandboxed Jinja renderer in
# validate_messages() below -- a stronger, purpose-built check that
# Tier 2 would conflict with.
excluded_fields = frozenset({'messages'})
# notification_configuration's schema is notification_type-dependent, but
# unlike Credential.inputs (admin-definable custom types with arbitrary
# secret fields), notification_type is a fixed models.CharField(choices=...)
# -- every possible backend class ships in awx/main/notifications/ and is
# known at code-authoring time. This is the exhaustive union, across all
# backends, of every init_parameters key with type "password" (verified:
# none of these names are used for a non-secret value in any backend):
# email/irc/webhook password, slack/pagerduty token, twilio account_token,
# grafana grafana_key, awssns aws_secret_access_key/aws_session_token.
excluded_json_keys = MappingProxyType(
{'notification_configuration': frozenset({'password', 'token', 'account_token', 'grafana_key', 'aws_secret_access_key', 'aws_session_token'})}
)
class Meta: class Meta:
model = NotificationTemplate model = NotificationTemplate
fields = ('*', 'organization', 'notification_type', 'notification_configuration', 'messages') fields = ('*', 'organization', 'notification_type', 'notification_configuration', 'messages')
@@ -5334,7 +5557,11 @@ class NotificationTemplateSerializer(BaseSerializer):
password_fields_to_forward = [] password_fields_to_forward = []
error_list = [] error_list = []
if 'notification_configuration' not in attrs: if 'notification_configuration' not in attrs:
return attrs # Still chain into CleanTextMixin.validate() (AAP-78694) so a
# PATCH that omits notification_configuration (e.g. name/
# description only) doesn't skip Tier 1/2 validation entirely --
# only the config-schema checks below are skipped.
return super(NotificationTemplateSerializer, self).validate(attrs)
if self.context['view'].kwargs and isinstance(self.context['view'], NotificationTemplateDetail): if self.context['view'].kwargs and isinstance(self.context['view'], NotificationTemplateDetail):
object_actual = self.context['view'].get_object() object_actual = self.context['view'].get_object()
else: else:
@@ -5425,7 +5652,7 @@ class NotificationSerializer(BaseSerializer):
return ret return ret
class LabelSerializer(BaseSerializer): class LabelSerializer(CleanTextMixin, BaseSerializer):
class Meta: class Meta:
model = Label model = Label
fields = ('*', '-description', 'organization') fields = ('*', '-description', 'organization')
@@ -5505,7 +5732,12 @@ class SchedulePreviewSerializer(BaseSerializer):
return value return value
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer): class ScheduleSerializer(PromptFieldCleanTextMixin, LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
# See WorkflowJobTemplateNodeSerializer.excluded_fields above -- same
# reasoning, and same requirement that this live directly on this class
# rather than on the shared LaunchConfigurationBaseSerializer base.
excluded_fields = frozenset({'extra_data'})
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
timezone = serializers.SerializerMethodField( timezone = serializers.SerializerMethodField(
@@ -5634,9 +5866,15 @@ class ReceptorAddressSerializer(BaseSerializer):
return obj.get_full_address() return obj.get_full_address()
class InstanceSerializer(BaseSerializer): class InstanceSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit'] show_capabilities = ['edit']
# hostname's existing HostnameRegexValidator (see extra_kwargs below)
# explicitly accepts IPv6 addresses, which contain colons that Tier 1's
# allowlist rejects. Demote to Tier 2 so IPv6 execution-node hostnames
# keep working; the existing validator remains the real charset gate.
name_fields = DEFAULT_NAME_FIELDS - {'hostname'}
consumed_capacity = serializers.SerializerMethodField() consumed_capacity = serializers.SerializerMethodField()
percent_capacity_remaining = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField()
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True) jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True)
@@ -5935,7 +6173,7 @@ class HostMetricSummaryMonthlySerializer(BaseSerializer):
fields = read_only_fields fields = read_only_fields
class InstanceGroupSerializer(BaseSerializer): class InstanceGroupSerializer(CleanTextMixin, BaseSerializer):
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
capacity = serializers.SerializerMethodField() capacity = serializers.SerializerMethodField()
consumed_capacity = serializers.SerializerMethodField() consumed_capacity = serializers.SerializerMethodField()
@@ -1358,3 +1358,96 @@ def test_external_credential_rbac_test_endpoint(post, alice, external_credential
external_credential.use_role.members.add(alice) external_credential.use_role.members.add(alice)
assert post(url, data, alice).status_code == 202 assert post(url, data, alice).status_code == 202
#
# CleanTextMixin on Credential.inputs (AAP-78694)
#
# Nested JSON strings always go through Tier 2. These tests only assert which
# keys are skipped: credential_type.secret_fields are excluded per-request via
# instance excluded_json_keys; everything else is still validated.
#
UNSAFE_INPUT = '<script>x</script>'
@pytest.fixture
def enforce_clean_text():
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
yield
@pytest.mark.django_db
def test_credential_inputs_rejects_unsafe_non_secret_key(post, admin, credentialtype_ssh, enforce_clean_text):
assert 'username' not in credentialtype_ssh.secret_fields
response = post(
reverse('api:credential_list'),
{
'name': 'ssh-cleantext-username',
'credential_type': credentialtype_ssh.id,
'user': admin.id,
'inputs': {'username': UNSAFE_INPUT, 'password': UNSAFE_INPUT},
},
admin,
)
assert response.status_code == 400
assert 'username' in response.data['inputs']
assert 'password' not in response.data['inputs']
@pytest.mark.django_db
def test_credential_inputs_skips_secret_keys(post, admin, credentialtype_ssh, enforce_clean_text):
assert 'password' in credentialtype_ssh.secret_fields
response = post(
reverse('api:credential_list'),
{
'name': 'ssh-cleantext-password',
'credential_type': credentialtype_ssh.id,
'user': admin.id,
'inputs': {'username': 'ok-user', 'password': UNSAFE_INPUT},
},
admin,
)
assert response.status_code == 201
@pytest.mark.django_db
def test_credential_inputs_secret_fields_follow_custom_type_schema(post, admin, organization, enforce_clean_text):
credential_type = CredentialType(
kind='cloud',
name='CustomCleanTextType',
inputs={
'fields': [
{'id': 'endpoint', 'label': 'Endpoint', 'type': 'string'},
{'id': 'token', 'label': 'Token', 'type': 'string', 'secret': True},
]
},
)
credential_type.save()
assert credential_type.secret_fields == ['token']
rejected = post(
reverse('api:credential_list'),
{
'name': 'custom-cleantext-endpoint',
'organization': organization.pk,
'credential_type': credential_type.pk,
'inputs': {'endpoint': UNSAFE_INPUT, 'token': 'ok-token'},
},
admin,
)
assert rejected.status_code == 400
assert 'endpoint' in rejected.data['inputs']
assert 'token' not in rejected.data['inputs']
accepted = post(
reverse('api:credential_list'),
{
'name': 'custom-cleantext-token',
'organization': organization.pk,
'credential_type': credential_type.pk,
'inputs': {'endpoint': 'https://example.invalid', 'token': UNSAFE_INPUT},
},
admin,
)
assert accepted.status_code == 201
@@ -64,6 +64,44 @@ def test_inventory_host_name_unique(scm_inventory, post, admin_user):
assert "A Group with that name already exists." in json.dumps(resp.data) assert "A Group with that name already exists." in json.dumps(resp.data)
@pytest.mark.django_db
def test_inventory_host_inline_port(scm_inventory, post, admin_user):
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
resp = post(
reverse('api:inventory_hosts_list', kwargs={'pk': scm_inventory.id}),
{'name': 'web.example.com:2222'},
admin_user,
expect=201,
)
assert resp.data['name'] == 'web.example.com'
assert '2222' in resp.data['variables']
@pytest.mark.django_db
def test_inventory_host_ipv6_name(scm_inventory, post, admin_user):
"""HostSerializer demotes name from Tier 1 so IPv6 (multiple colons)
is not rejected once enforcement is on (AAP-78694)."""
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
resp = post(
reverse('api:inventory_hosts_list', kwargs={'pk': scm_inventory.id}),
{'name': '2001:db8::1'},
admin_user,
expect=201,
)
assert resp.data['name'] == '2001:db8::1'
@pytest.mark.django_db
def test_inventory_host_invalid_inline_port(scm_inventory, post, admin_user):
resp = post(
reverse('api:inventory_hosts_list', kwargs={'pk': scm_inventory.id}),
{'name': 'web.example.com:99999'},
admin_user,
expect=400,
)
assert 'Invalid port' in json.dumps(resp.data)
@pytest.mark.django_db @pytest.mark.django_db
def test_inventory_host_list_ordering(scm_inventory, get, admin_user): def test_inventory_host_list_ordering(scm_inventory, get, admin_user):
# create 3 hosts, hit the inventory host list view 3 times and get the order visible there each time and compare # create 3 hosts, hit the inventory host list view 3 times and get the order visible there each time and compare
+42
View File
@@ -1,6 +1,7 @@
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
from unittest import mock
from awx.api.versioning import reverse from awx.api.versioning import reverse
@@ -629,3 +630,44 @@ def test_bulk_host_create_performance_large_inventory(organization, inventory, p
assert len(response.data['hosts']) == 10 assert len(response.data['hosts']) == 10
assert Host.objects.filter(inventory=inventory).count() == 10010 assert Host.objects.filter(inventory=inventory).count() == 10010
@pytest.mark.django_db
def test_bulk_job_launch_rejects_unsafe_limit(organization, inventory, project, post, user):
"""PromptFieldCleanTextMixin._run_clean_text_validation must raise when
enforcement is on -- BulkJobNodeSerializer skips the normal validate()
chain that would otherwise do this (AAP-78694)."""
normal_user = user('normal_user', False)
organization.member_role.members.add(normal_user)
jt = JobTemplate.objects.create(name='my-jt', inventory=inventory, project=project, playbook='helloworld.yml')
jt.execute_role.members.add(normal_user)
with mock.patch('awx.api.serializers.get_setting', return_value=True):
response = post(
reverse('api:bulk_job_launch'),
{'name': 'Bulk Job Launch', 'jobs': [{'unified_job_template': jt.id, 'limit': '<script>x</script>'}]},
normal_user,
expect=400,
)
assert 'limit' in str(response.data)
@pytest.mark.django_db
def test_bulk_job_launch_allows_jinja_extra_vars(organization, inventory, project, post, user):
"""BulkJobLaunchSerializer stringifies extra_vars before CleanTextMixin
runs; excluded_fields must keep legitimate Jinja from being rejected
(AAP-78694)."""
normal_user = user('normal_user', False)
organization.member_role.members.add(normal_user)
jt = JobTemplate.objects.create(name='my-jt', inventory=inventory, project=project, playbook='helloworld.yml')
jt.execute_role.members.add(normal_user)
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
post(
reverse('api:bulk_job_launch'),
{
'name': 'Bulk Job Launch',
'jobs': [{'unified_job_template': jt.id}],
'extra_vars': {'foo': '{{ bar }}'},
},
normal_user,
expect=201,
)
+27
View File
@@ -271,3 +271,30 @@ def test_notification_template_copy(post, get, notification_template_with_encryp
assert decrypt_field(notification_template_with_encrypt, 'notification_configuration', 'token') == decrypt_field( assert decrypt_field(notification_template_with_encrypt, 'notification_configuration', 'token') == decrypt_field(
notification_template_copy, 'notification_configuration', 'token' notification_template_copy, 'notification_configuration', 'token'
) )
@pytest.mark.django_db
def test_job_template_copy_rejects_same_name(post, job_template, admin):
response = post(
reverse('api:job_template_copy', kwargs={'pk': job_template.pk}),
{'name': job_template.name},
admin,
expect=400,
)
assert 'already named' in str(response.data)
@pytest.mark.django_db
def test_job_template_copy_rejects_unsafe_name(post, job_template, admin):
"""CopySerializer.super().validate() must run CleanTextMixin — the same-name
check above never reaches it (AAP-78694)."""
unsafe_name = '<script>x</script>'
assert job_template.name != unsafe_name
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
response = post(
reverse('api:job_template_copy', kwargs={'pk': job_template.pk}),
{'name': unsafe_name},
admin,
expect=400,
)
assert 'name' in response.data
@@ -111,6 +111,20 @@ def test_notification_template_simple_patch(patch, notification_template, admin)
patch(reverse('api:notification_template_detail', kwargs={'pk': notification_template.id}), {'name': 'foo'}, admin, expect=200) patch(reverse('api:notification_template_detail', kwargs={'pk': notification_template.id}), {'name': 'foo'}, admin, expect=200)
@pytest.mark.django_db
def test_notification_template_patch_rejects_unsafe_name(patch, notification_template, admin):
"""Name-only PATCH omits notification_configuration; that branch must still
chain into CleanTextMixin.validate() (AAP-78694)."""
with mock.patch('ansible_base.lib.serializers.mixins.get_setting', return_value=True):
response = patch(
reverse('api:notification_template_detail', kwargs={'pk': notification_template.id}),
{'name': '<script>x</script>'},
admin,
expect=400,
)
assert 'name' in response.data
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_template_invalid_notification_type(patch, notification_template, admin): def test_notification_template_invalid_notification_type(patch, notification_template, admin):
patch(reverse('api:notification_template_detail', kwargs={'pk': notification_template.id}), {'notification_type': 'invalid'}, admin, expect=400) patch(reverse('api:notification_template_detail', kwargs={'pk': notification_template.id}), {'notification_type': 'invalid'}, admin, expect=400)
@@ -0,0 +1,116 @@
from unittest import mock
import pytest
from rest_framework.exceptions import PermissionDenied, ValidationError
from ansible_base.lib.serializers.mixins import CleanTextMixin
from awx.api.serializers import (
BulkJobNodeSerializer,
CopySerializer,
CredentialSerializer,
ExecutionEnvironmentSerializer,
HostSerializer,
)
UNSAFE_INPUT = '<script>x</script>'
def _passthrough_validate(self, attrs):
return attrs
class TestCopySerializerValidate:
def test_rejects_copy_with_the_same_name(self):
original = mock.Mock()
original.name = 'keep-me'
view = mock.Mock()
view.get_object.return_value = original
serializer = CopySerializer(context={'view': view})
with pytest.raises(ValidationError) as exc:
serializer.validate({'name': 'keep-me'})
assert 'already named' in str(exc.value.detail)
@pytest.mark.django_db
class TestHostSerializerPortParsing:
def test_extracts_inline_ssh_port(self):
serializer = HostSerializer()
assert serializer._get_host_port_from_name('web.example.com:2222') == ('web.example.com', 2222)
@pytest.mark.parametrize('name', ['web.example.com:0', 'web.example.com:65536', 'web.example.com:notaport'])
def test_rejects_invalid_inline_port(self, name):
serializer = HostSerializer()
with pytest.raises(ValidationError):
serializer._get_host_port_from_name(name)
def test_validate_moves_port_into_ansible_ssh_port(self):
serializer = HostSerializer()
with mock.patch.object(CleanTextMixin, 'validate', _passthrough_validate):
attrs = serializer.validate({'name': 'web.example.com:2222'})
assert attrs['name'] == 'web.example.com'
assert '"ansible_ssh_port": 2222' in attrs['variables']
@pytest.mark.django_db
class TestCredentialSerializerValidate:
def test_rejects_updates_to_managed_credentials(self):
serializer = CredentialSerializer()
serializer.instance = mock.Mock(managed=True)
with pytest.raises(PermissionDenied):
serializer.validate({'name': 'nope'})
def test_fail_closed_excludes_inputs_when_type_is_unknown(self):
serializer = CredentialSerializer()
with mock.patch.object(CleanTextMixin, 'validate', _passthrough_validate):
serializer.validate({'name': 'no-type', 'inputs': {'username': UNSAFE_INPUT}})
assert 'inputs' in serializer.excluded_fields
@pytest.mark.django_db
class TestBulkJobNodeCleanText:
def test_run_clean_text_validation_raises_when_enforced(self):
serializer = BulkJobNodeSerializer()
with mock.patch('awx.api.serializers.get_setting', return_value=True):
with pytest.raises(ValidationError) as exc:
serializer._run_clean_text_validation({'limit': UNSAFE_INPUT})
assert 'limit' in exc.value.detail
@pytest.mark.django_db
class TestExecutionEnvironmentSerializerValidate:
def test_rejects_non_registry_credentials(self):
serializer = ExecutionEnvironmentSerializer()
credential = mock.Mock(kind='ssh')
with pytest.raises(ValidationError):
serializer.validate_credential(credential)
def test_accepts_registry_credentials(self):
credential = mock.Mock(kind='registry')
assert ExecutionEnvironmentSerializer().validate_credential(credential) is credential
def test_rejects_organization_change(self):
serializer = ExecutionEnvironmentSerializer()
serializer.instance = mock.Mock(organization_id=1)
organization = mock.Mock(pk=2)
with pytest.raises(ValidationError) as exc:
serializer.validate({'organization': organization})
assert 'organization' in exc.value.detail
def test_allows_same_organization(self):
serializer = ExecutionEnvironmentSerializer()
serializer.instance = mock.Mock(organization_id=1)
org = mock.Mock(pk=1)
with mock.patch.object(CleanTextMixin, 'validate', _passthrough_validate):
attrs = serializer.validate({'organization': org})
assert attrs['organization'] is org
@@ -30,6 +30,14 @@ class TestNotificationTemplateSerializer:
{'started': {'body': '{{ job_metadata }}'}}, {'started': {'body': '{{ job_metadata }}'}},
{'started': {'body': '{{ job.summary_fields.inventory.total_hosts }}'}}, {'started': {'body': '{{ job.summary_fields.inventory.total_hosts }}'}},
{'started': {'body': 'Iñtërnâtiônàlizætiøn'}}, {'started': {'body': 'Iñtërnâtiônàlizætiøn'}},
{
'workflow_approval': {
'running': {'message': 'running'},
'approved': None,
'timed_out': {'message': 'timed out'},
'denied': {'body': 'denied'},
}
},
], ],
) )
def test_valid_messages(self, valid_messages): def test_valid_messages(self, valid_messages):
@@ -55,6 +63,8 @@ class TestNotificationTemplateSerializer:
{'started': {'message': '{{ job.id | bad_filter }}'}}, {'started': {'message': '{{ job.id | bad_filter }}'}},
{'started': {'message': '{{ job.__class__ }}'}}, {'started': {'message': '{{ job.__class__ }}'}},
{'started': {'message': 'Newlines \n not allowed\n'}}, {'started': {'message': 'Newlines \n not allowed\n'}},
{'workflow_approval': {'invalid_subevent': {}}},
{'workflow_approval': {'running': 'should_be_dict'}},
], ],
) )
def test_invalid__messages(self, invalid_messages): def test_invalid__messages(self, invalid_messages):
@@ -0,0 +1,351 @@
from unittest import mock
import pytest
from rest_framework.exceptions import ValidationError
from awx.api.serializers import (
ActivityStreamSerializer,
AdHocCommandRelaunchSerializer,
AnsibleFactsSerializer,
GroupTreeSerializer,
HostSerializer,
JobCreateScheduleSerializer,
JobHostSummarySerializer,
JobRelaunchSerializer,
NotificationSerializer,
OpaQueryPathMixin,
ProjectUpdateEventSerializer,
UnifiedJobSerializer,
UnifiedJobStdoutSerializer,
WorkflowApprovalSerializer,
WorkflowJobLaunchSerializer,
reverse_gfk,
)
from awx.main.constants import ACTIVE_STATES
from awx.main.models import JobLaunchConfig, WorkflowApproval, WorkflowJobTemplate
def _mock(**kwargs):
"""Build a Mock with a real `.name` attribute.
unittest.mock.Mock(name=...) only sets the mock's repr name, so
getattr(obj, 'name') would return another Mock instead of the string.
"""
name = kwargs.pop('name', None)
obj = mock.Mock(**kwargs)
if name is not None:
obj.name = name
return obj
def test_reverse_gfk_empty_when_object_missing():
assert reverse_gfk(None, None) == {}
assert reverse_gfk(object(), None) == {}
def test_reverse_gfk_uses_absolute_url():
class Organization:
def get_absolute_url(self, request=None):
return '/api/v2/organizations/1/'
assert reverse_gfk(Organization(), None) == {'organization': '/api/v2/organizations/1/'}
def test_opa_query_path_rejects_unencoded_url():
serializer = OpaQueryPathMixin()
with pytest.raises(ValidationError):
serializer.validate_opa_query_path('not a valid path')
def test_unified_job_stdout_types():
assert UnifiedJobStdoutSerializer().get_types() == [
'project_update',
'inventory_update',
'job',
'ad_hoc_command',
'system_job',
]
def test_unified_job_launched_by():
serializer = UnifiedJobSerializer()
assert serializer.get_launched_by(None) is None
obj = mock.Mock(launched_by={'id': 7})
assert serializer.get_launched_by(obj) == {'id': 7}
def test_unified_job_sub_serializer_workflow_approval():
obj = mock.MagicMock(spec=WorkflowApproval)
assert UnifiedJobSerializer().get_sub_serializer(obj) is WorkflowApprovalSerializer
def test_ansible_facts_to_representation():
obj = mock.Mock(ansible_facts={'os': 'rhel'})
assert AnsibleFactsSerializer().to_representation(obj) == {'os': 'rhel'}
def test_group_tree_children_none():
assert GroupTreeSerializer().get_children(None) == {}
def test_adhoc_relaunch_to_representation():
serializer = AdHocCommandRelaunchSerializer()
assert serializer.to_representation(None) == {}
obj = mock.Mock(passwords_needed_to_start=['ssh_password'])
assert serializer.to_representation(obj) == {'ssh_password': ''}
class TestHostToRepresentation:
def test_related_includes_last_job_links(self):
serializer = HostSerializer()
serializer.reverse = mock.Mock(return_value='/x/')
obj = mock.Mock(pk=1, instance_id=9)
obj.inventory.kind = 'constructed'
obj.inventory.pk = 2
obj.latest_summary = mock.Mock(pk=3, job_id=4)
with mock.patch('awx.api.serializers.BaseSerializer.get_related', return_value={}):
related = serializer.get_related(obj)
assert 'last_job_host_summary' in related
assert 'last_job' in related
def test_empty_obj_returns_parent_payload(self):
serializer = HostSerializer()
with mock.patch('awx.api.serializers.BaseSerializer.to_representation', return_value={'inventory': 1}):
assert serializer.to_representation(None) == {'inventory': 1}
def test_nulls_missing_inventory(self):
serializer = HostSerializer()
obj = mock.Mock(inventory=None)
with mock.patch('awx.api.serializers.BaseSerializer.to_representation', return_value={'inventory': 9}):
assert serializer.to_representation(obj)['inventory'] is None
@pytest.mark.django_db
class TestJobRelaunchSerializerPaths:
def test_missing_credential_passwords(self):
serializer = JobRelaunchSerializer()
serializer.instance = mock.Mock(passwords_needed_to_start=['ssh_password', 'become_password'])
with pytest.raises(ValidationError):
serializer.validate_credential_passwords({'ssh_password': ''})
def test_to_representation_injects_password_keys(self):
view = mock.Mock(_raw_data_form_marker=True)
serializer = JobRelaunchSerializer(context={'view': view})
obj = mock.Mock(passwords_needed_to_start=['ssh_password'])
with mock.patch('awx.api.serializers.BaseSerializer.to_representation', return_value={}):
assert serializer.to_representation(obj)['ssh_password'] == ''
def test_passwords_needed_to_start(self):
serializer = JobRelaunchSerializer()
assert serializer.get_passwords_needed_to_start(None) == ''
obj = mock.Mock(passwords_needed_to_start=['ssh_password'])
assert serializer.get_passwords_needed_to_start(obj) == ['ssh_password']
def test_retry_counts_while_running(self):
serializer = JobRelaunchSerializer()
obj = mock.Mock(status=next(iter(ACTIVE_STATES)))
assert 'not available' in str(serializer.get_retry_counts(obj))
def test_retry_counts_when_finished(self):
serializer = JobRelaunchSerializer()
obj = mock.Mock(status='successful')
obj.retry_qs.return_value.count.return_value = 2
counts = serializer.get_retry_counts(obj)
assert counts['all'] == 2
assert counts['failed'] == 2
def test_validate_missing_project(self):
serializer = JobRelaunchSerializer()
serializer.instance = mock.Mock(project=None, inventory=mock.Mock(pending_deletion=False))
with pytest.raises(ValidationError):
serializer.validate({})
def test_validate_missing_inventory(self):
serializer = JobRelaunchSerializer()
serializer.instance = mock.Mock(project=mock.Mock(), inventory=None)
with pytest.raises(ValidationError):
serializer.validate({})
def test_validate_inventory_pending_deletion(self):
serializer = JobRelaunchSerializer()
serializer.instance = mock.Mock(project=mock.Mock(), inventory=mock.Mock(pending_deletion=True))
with pytest.raises(ValidationError):
serializer.validate({})
class TestJobCreateScheduleSerializerPaths:
def test_can_schedule(self):
obj = mock.Mock(can_schedule=True)
assert JobCreateScheduleSerializer().get_can_schedule(obj) is True
def test_summarize_copies_fk_fields(self):
obj = _mock(id=3, name='inv', description='d')
summary = JobCreateScheduleSerializer._summarize('host', obj)
assert summary['id'] == 3
assert summary['name'] == 'inv'
def test_get_prompts_summarizes_related_objects(self):
inventory = _mock(id=1, name='inv', description='', has_active_failures=False)
ee = _mock(id=2, name='ee', description='', image='img')
cred = _mock(id=3, name='cred', description='', kind='ssh', cloud=False, kubernetes=False, credential_type_id=1)
ig = _mock(id=4, name='ig', is_container_group=False)
config = mock.Mock()
config.prompts_dict.return_value = {
'inventory': inventory,
'execution_environment': ee,
'credentials': [cred],
'instance_groups': [ig],
'labels': True,
}
job = mock.Mock(launch_config=config)
serializer = JobCreateScheduleSerializer()
serializer._summary_field_labels = mock.Mock(return_value=[{'id': 9, 'name': 'l'}])
prompts = serializer.get_prompts(job)
assert prompts['inventory']['id'] == 1
assert prompts['execution_environment']['id'] == 2
assert prompts['credentials'][0]['id'] == 3
assert prompts['instance_groups'][0]['id'] == 4
assert prompts['labels'] == [{'id': 9, 'name': 'l'}]
def test_get_prompts_without_launch_config(self):
class JobWithoutConfig:
@property
def launch_config(self):
raise JobLaunchConfig.DoesNotExist()
prompts = JobCreateScheduleSerializer().get_prompts(JobWithoutConfig())
assert 'all' in prompts
class TestNotificationSerializerBody:
def test_webhook_dict_body(self):
obj = mock.Mock(notification_type='webhook', body={'body': 'hello'})
assert NotificationSerializer().get_body(obj) == 'hello'
def test_webhook_json_string_body(self):
obj = mock.Mock(notification_type='pagerduty', body='{"ok": true}')
assert NotificationSerializer().get_body(obj) == {'ok': True}
def test_webhook_invalid_json_string_body(self):
obj = mock.Mock(notification_type='awssns', body='not-json')
assert NotificationSerializer().get_body(obj) == 'not-json'
def test_plain_body_passthrough(self):
obj = mock.Mock(notification_type='email', body='text')
assert NotificationSerializer().get_body(obj) == 'text'
class TestJobHostSummarySerializerPaths:
def test_get_related_includes_host(self):
serializer = JobHostSummarySerializer()
serializer.reverse = mock.Mock(side_effect=lambda *a, **k: '/x/')
obj = mock.Mock(job=mock.Mock(pk=1), host=mock.Mock(pk=2))
with mock.patch('awx.api.serializers.BaseSerializer.get_related', return_value={}):
related = serializer.get_related(obj)
assert 'job' in related
assert 'host' in related
def test_summary_fields_adds_job_template(self):
serializer = JobHostSummarySerializer()
obj = mock.Mock()
obj.job.job_template.id = 11
obj.job.job_template.name = 'jt'
with mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', return_value={'job': {}}):
summary = serializer.get_summary_fields(obj)
assert summary['job']['job_template_id'] == 11
assert summary['job']['job_template_name'] == 'jt'
def test_summary_fields_tolerates_missing_job(self):
serializer = JobHostSummarySerializer()
obj = mock.Mock()
with mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', return_value={}):
assert serializer.get_summary_fields(obj) == {}
class TestWorkflowJobLaunchSerializerPaths:
def test_survey_enabled(self):
serializer = WorkflowJobLaunchSerializer()
assert serializer.get_survey_enabled(None) is False
obj = mock.Mock(survey_enabled=True, survey_spec={'spec': []})
assert serializer.get_survey_enabled(obj) is True
def test_workflow_job_template_data(self):
obj = _mock(name='wf', id=5, description='d')
assert WorkflowJobLaunchSerializer().get_workflow_job_template_data(obj) == {'name': 'wf', 'id': 5, 'description': 'd'}
def test_defaults_inventory_and_labels(self):
mapping = {'inventory': None, 'labels': None, 'limit': None}
obj = mock.Mock()
obj.inventory.name = 'inv'
obj.inventory.pk = 8
obj.limit = 'webservers'
label = _mock(id=1, name='prod')
obj.labels.all.return_value = [label]
with mock.patch.object(WorkflowJobTemplate, 'get_ask_mapping', return_value=mapping):
defaults = WorkflowJobLaunchSerializer().get_defaults(obj)
assert defaults['inventory'] == {'name': 'inv', 'id': 8}
assert defaults['labels'] == [{'id': 1, 'name': 'prod'}]
assert defaults['limit'] == 'webservers'
class TestActivityStreamSerializerPaths:
def test_get_changes(self):
serializer = ActivityStreamSerializer()
assert serializer.get_changes(None) == {}
assert serializer.get_changes(mock.Mock(changes='{"a": 1}')) == {'a': 1}
assert serializer.get_changes(mock.Mock(changes='not-json')) == {}
def test_object_association(self):
serializer = ActivityStreamSerializer()
assert serializer.get_object_association(mock.Mock(object_relationship_type='')) == ''
assert serializer.get_object_association(mock.Mock(object_relationship_type='awx.main.models.inventory.Inventory.admin_role')) == 'role'
rel = 'awx.main.models.organization.Organization_notification_templates_success'
assert 'notification' in serializer.get_object_association(mock.Mock(object_relationship_type=rel))
def test_object_type_role(self):
serializer = ActivityStreamSerializer()
assert serializer.get_object_type(mock.Mock(object_relationship_type='')) == ''
rel = 'awx.main.models.inventory.Inventory.admin_role'
assert serializer.get_object_type(mock.Mock(object_relationship_type=rel)) == 'inventory'
class TestProjectUpdateEventSanitization:
def test_sanitizes_git_event_data(self):
serializer = ProjectUpdateEventSerializer()
obj = mock.Mock(event_data={'task_action': 'git', 'msg': 'ok'})
assert serializer.get_event_data(obj)['task_action'] == 'git'
def test_returns_empty_on_sanitize_failure(self):
serializer = ProjectUpdateEventSerializer()
obj = mock.Mock(event_data={'task_action': 'svn'})
with mock.patch('awx.api.serializers.json.dumps', side_effect=ValueError('boom')):
assert serializer.get_event_data(obj) == {}
def test_passthrough_for_other_actions(self):
serializer = ProjectUpdateEventSerializer()
obj = mock.Mock(event_data={'task_action': 'debug', 'msg': 'hi'})
assert serializer.get_event_data(obj) == obj.event_data
class TestUnifiedJobSummaryFields:
def test_spawned_by_workflow_copies_job_summary(self):
serializer = UnifiedJobSerializer()
workflow_job = _mock(id=10, name='wf', description='', status='successful', failed=False, elapsed=1.5, type='workflow_job', canceled_on=None)
obj = mock.Mock(spawned_by_workflow=True, ancestor_job=None)
obj.unified_job_node.workflow_job = workflow_job
with mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', return_value={}):
summary = serializer.get_summary_fields(obj)
assert summary['source_workflow_job']['id'] == 10
assert summary['source_workflow_job']['name'] == 'wf'
def test_unified_job_elapsed_for_running_job():
serializer = UnifiedJobSerializer()
started = mock.Mock()
td = mock.Mock(microseconds=0, seconds=5, days=0)
obj = mock.Mock(pk=1, started=started, finished=None, job_explanation='')
with mock.patch.object(UnifiedJobSerializer, 'get_sub_serializer', return_value=None):
with mock.patch('awx.api.serializers.BaseSerializer.to_representation', return_value={'elapsed': 0, 'job_explanation': ''}):
with mock.patch('awx.api.serializers.now', return_value=mock.Mock(__sub__=lambda self, other: td)):
ret = serializer.to_representation(obj)
assert ret['elapsed'] == 5.0