Skip to content

Unit tests for osism/tasks/conductor/utils.py #2202

@berendt

Description

@berendt

Background

Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). First step in Tier 2 (#2199): the small, mostly-pure helpers in osism/tasks/conductor/utils.py that are used everywhere else in the conductor package. Covering them first means later tier issues can rely on them being well-tested.

The five targets are largely pure dict/string logic. The only external dependency is ansible.parsing.vault.VaultLib (used by deep_decrypt and load_yaml_file), which is straightforward to mock.

Scope

Add tests/unit/tasks/conductor/test_utils.py covering five functions in osism/tasks/conductor/utils.py. Out of scope (separate issues): get_redfish_connection, _get_conductor_redfish_*, check_task_lock_and_exit, mask_secrets / _mask_secrets_inplace, get_vault (only indirectly via load_yaml_file).

Test targets

deep_compare(a, b, updates)utils.py:16

Recursively walks a and writes diffs vs. b into updates. Pure function, no mocking.

  • Identical dicts → updates stays empty
  • Key in a, missing in b → recorded in updates
  • Same key, different scalar values → recorded
  • Nested dict where inner value differs → nested entry in updates
  • Nested dict where inner subtree is fully equal → that branch is removed from updates (the cleanup branch)
  • Empty aupdates untouched

deep_merge(a, b)utils.py:32

Recursively merges b into a in place. Pure function, no mocking.

  • Disjoint keys → both kept
  • Overlapping scalar key → b's value wins
  • Both values are dicts → merged recursively
  • Type mismatch (dict vs scalar) → b's value overwrites
  • Sentinel \"DELETE\" value in b → corresponding key removed from a
  • \"DELETE\" for a key that does not exist in a → no error
  • Mutation: assert a is mutated in place and b is unchanged

deep_decrypt(a, vault)utils.py:47

Recursively decrypts vault-encrypted strings inside dicts/lists. Mock the vault parameter (a VaultLib instance) — patch its is_encrypted and decrypt methods.

  • a is None → returns immediately, no calls on vault
  • Dict with one encrypted string value → replaced with decoded/stripped plaintext
  • Dict with one plaintext value → untouched (is_encrypted returns False)
  • Nested dict / nested list → recursion descends
  • List containing encrypted strings → values replaced in place
  • Decrypt raises Exception for a dict value → key is removed from the dict
  • Decrypt raises Exception for a list element → element is left untouched (pass branch)
  • Verify decrypt returns bytes and the result is .decode().strip()

_is_secret_key(key)utils.py:280

  • Non-string input (int, None, bytes) → False
  • \"password\", \"PASSWORD\", \"db_password\"True
  • \"secret\", \"client_secret\", \"SECRET_KEY\"True
  • \"ironic_osism_foo\", \"IRONIC_OSISM_BAR\"True
  • \"username\", \"host\", \"ironic_other\" (does not match the prefix exactly) → False
  • Empty string → False

load_yaml_file(path)utils.py:95

Reads a file, optionally vault-decrypts it, and returns the parsed YAML. Uses tmp_path for filesystem cases; patch osism.tasks.conductor.utils.get_vault for the encrypted-file case.

  • Path does not exist → returns None (no exception)
  • Plain YAML file → returns parsed dict
  • Empty YAML file → returns None (yaml.safe_load on empty input)
  • Malformed YAML → returns None (yaml.YAMLError swallowed, debug-logged)
  • OSError on read (e.g. patch builtins.open to raise) → returns None
  • Encrypted file: patch VaultLib.is_encrypted to True and patch get_vault().decrypt to return valid YAML bytes → returns parsed dict
  • Encrypted file where decrypt raises AnsibleError → returns None

Mocking hints

  • For deep_decrypt: build a MagicMock(spec=VaultLib) and configure is_encrypted.side_effect / decrypt.side_effect per case — no need to touch the real ansible-vault stack.
  • For load_yaml_file (encrypted branch): patch osism.tasks.conductor.utils.get_vault so the test never reaches osism.utils.get_ansible_vault_password() (which would hit Redis).
  • For the is_encrypted probe inside load_yaml_file (line ~105): it constructs a fresh VaultLib() just to call .is_encrypted(). Either patch osism.tasks.conductor.utils.VaultLib or pass content that the real method correctly classifies (a real plaintext file works without any patch).
  • File I/O: prefer tmp_path over patching open, except for the OSError case.

Definition of Done

  • tests/unit/tasks/__init__.py, tests/unit/tasks/conductor/__init__.py created
  • tests/unit/tasks/conductor/test_utils.py covers all listed cases
  • pytest --cov=osism.tasks.conductor.utils shows 100 % for the five target functions (other functions in the file may stay uncovered for now)
  • pipenv run pytest tests/unit/tasks/conductor/test_utils.py passes locally
  • flake8, mypy, python-black remain green
  • Zuul job python-osism-unit-tests passes

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions