commit d265b6fd15ec86aa218285102c6fa6b6869ec712 parent 676215d9fa4b46f659ca497da2cd560ef91000fd Author: Asher Morgan <asher@ashermorgan.net> Date: Sun, 13 Sep 2026 08:11:31 -0700 Add basic e2e tests Adapted from Ansible's connection_local integration tests. Diffstat:
13 files changed, 465 insertions(+), 0 deletions(-)
diff --git a/tests/e2e/files/sudoshim.sh b/tests/e2e/files/sudoshim.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# A wrapper around `sudo` that replaces the expected password prompt string (if given) with a bogus value. +# This allows testing situations where the expected password prompt is not found. +# This wrapper also supports becoming an intermediate user before executing sudo, to support testing as root. + +set -eu + +args=("${@}") +intermediate_user_idx='' +original_prompt='' +shell_executable='' +shell_command='' +original_prompt_idx='' + +# some args show up after others, but we need them before processing args that came before them +for i in "${!args[@]}"; do + case "${args[$i]}" in + "-p") + original_prompt="${args[i+1]}" + original_prompt_idx="${i}" + ;; + "-c") + shell_executable="${args[i-1]}" + shell_command="${args[i+1]}" + ;; + esac +done + +for i in "${!args[@]}"; do + case "${args[$i]}" in + "--inject-stdout-noise") + echo "stdout noise" + unset "args[i]" + ;; + "--inject-stderr-noise") + echo >&2 "stderr noise" + unset "args[i]" + ;; + "--bogus-prompt") + args[original_prompt_idx+1]="BOGUSPROMPT" + unset "args[i]" + ;; + "--intermediate-user") + intermediate_user_idx="${i}" + ;; + "--close-stderr") + >&2 echo "some injected stderr, EOF now" + exec 2>&- # close stderr, doesn't seem to work on Ubuntu 24.04 (either not closed or not seen in Python?) + unset "args[i]" + ;; + "--sleep-before-sudo") + sleep 3 + unset "args[i]" + ;; + "--pretend-to-be-broken-passwordless-sudo") + echo '{"hello":"not a module response"}' + exit 0 + ;; + "--pretend-to-be-broken-sudo") + echo -n "${original_prompt}" + read -rs + echo + echo "success, but not invoking given command" + exit 0 + ;; + "--pretend-to-be-sudo") + echo -n "${original_prompt}" + read -rs + echo + echo "success, invoking given command" + "${shell_executable}" -c "${shell_command}" + exit 0 + ;; + esac +done + +if [[ "${intermediate_user_idx}" ]]; then + # The current user can sudo without a password prompt, so delegate to an intermediate user first. + intermediate_user_name="${args[intermediate_user_idx+1]}" + + unset "args[intermediate_user_idx]" + unset "args[intermediate_user_idx+1]" + + exec sudo -n -u "${intermediate_user_name}" sudo -k "${args[@]}" +else + # The current user requires a password to sudo, so sudo can be used directly. + exec sudo -k "${args[@]}" +fi diff --git a/tests/e2e/pipelining.cfg b/tests/e2e/pipelining.cfg @@ -0,0 +1,2 @@ +[connection] +pipelining = true diff --git a/tests/e2e/playbooks.yml b/tests/e2e/playbooks.yml @@ -0,0 +1,13 @@ +test1.yml: + path: test_connection.yml + +test2.yml: + path: test_connection.yml + config: pipelining.cfg + +test3.yml: + path: test_become_password_handling.yml + +test4.yml: + path: test_become_password_handling.yml + config: pipelining.cfg diff --git a/tests/e2e/roles/setup_become_user_pair/defaults/main.yml b/tests/e2e/roles/setup_become_user_pair/defaults/main.yml @@ -0,0 +1,2 @@ +target_user_name: ansibletest0 # target unprivileged user +intermediate_user_name: ansibletest1 # an intermediate user diff --git a/tests/e2e/roles/setup_become_user_pair/tasks/main.yml b/tests/e2e/roles/setup_become_user_pair/tasks/main.yml @@ -0,0 +1,24 @@ +- name: create an unprivileged user on target + include_role: + name: setup_test_user + public: true + vars: + test_user_name: '{{ target_user_name }}' + test_user_groups: '{{ target_user_groups | default(omit) }}' + +- name: capture target user password + set_fact: + target_user_password: '{{ test_user_plaintext_password }}' + +- name: create an intermediate user on target with password-required sudo ability + include_role: + name: setup_test_user + public: true + vars: + test_user_name: "{{ intermediate_user_name }}" + test_user_groups: '{{ intermediate_user_groups | default(omit) }}' + test_user_allow_sudo: true + +- name: capture config values, intermediate user password from role + set_fact: + intermediate_user_password: "{{ test_user_plaintext_password }}" diff --git a/tests/e2e/roles/setup_test_user/defaults/main.yml b/tests/e2e/roles/setup_test_user/defaults/main.yml @@ -0,0 +1,5 @@ +# true/false/nopasswd +test_user_allow_sudo: false +test_user_name: ansibletest0 +test_user_group: ~ +test_user_groups: ~ diff --git a/tests/e2e/roles/setup_test_user/handlers/main.yml b/tests/e2e/roles/setup_test_user/handlers/main.yml @@ -0,0 +1,12 @@ +- name: delete test user + user: + name: "{{ item }}" + state: absent + remove: yes + force: yes + loop: "{{ delete_users }}" + +- name: delete test user sudoers file + file: + path: '{{ sudoers_path ~ ".d/" ~ test_user_name }}' + state: absent diff --git a/tests/e2e/roles/setup_test_user/tasks/default.yml b/tests/e2e/roles/setup_test_user/tasks/default.yml @@ -0,0 +1,9 @@ +- name: set plaintext password + no_log: yes + set_fact: + test_user_plaintext_password: "{{ lookup('password', '/dev/null') }}" + +- name: set hashed password + no_log: yes + set_fact: + test_user_hashed_password: "{{ test_user_plaintext_password | password_hash('sha512') }}" diff --git a/tests/e2e/roles/setup_test_user/tasks/macosx.yml b/tests/e2e/roles/setup_test_user/tasks/macosx.yml @@ -0,0 +1,15 @@ +- name: set variables + set_fact: + test_user_group: staff + test_user_groups: + - com.apple.access_ssh + +- name: set plaintext password + no_log: yes + set_fact: + test_user_plaintext_password: "{{ lookup('password', '/dev/null') }}" + +- name: set hashed password + no_log: yes + set_fact: + test_user_hashed_password: "{{ test_user_plaintext_password }}" diff --git a/tests/e2e/roles/setup_test_user/tasks/main.yml b/tests/e2e/roles/setup_test_user/tasks/main.yml @@ -0,0 +1,45 @@ +- name: gather distribution facts + gather_facts: + gather_subset: distribution + when: ansible_distribution is not defined + +- name: include distribution specific tasks + include_tasks: "{{ lookup('first_found', params) }}" + vars: + params: + files: + - "{{ ansible_distribution | lower }}.yml" + - default.yml + paths: + - tasks + +- set_fact: + delete_users: "{{ (delete_users | default([])) + [test_user_name] }}" + +- name: create test user + user: + name: "{{ test_user_name }}" + group: "{{ test_user_group or omit }}" + groups: "{{ test_user_groups or omit }}" + password: "{{ test_user_hashed_password or omit }}" + register: test_user + notify: + - delete test user + +- name: maybe configure sudo + include_tasks: sudo_config.yml + when: test_user_allow_sudo != False + +- name: run whoami as the test user + shell: whoami + vars: + # ansible_become_method and ansible_become_flags are not set, allowing them to be provided by inventory + ansible_become: yes + ansible_become_user: "{{ test_user_name }}" + ansible_become_password: "{{ test_user_plaintext_password }}" + register: whoami + +- name: verify becoming the test user worked + assert: + that: + - whoami.stdout == test_user_name diff --git a/tests/e2e/roles/setup_test_user/tasks/sudo_config.yml b/tests/e2e/roles/setup_test_user/tasks/sudo_config.yml @@ -0,0 +1,15 @@ +- name: probe for sudoers config path + shell: visudo -c + ignore_errors: true + register: visudo_result + +- set_fact: + sudoers_path: '{{ ((visudo_result.stdout ~ visudo_result.stderr) | regex_search("(/.*sudoers).*:", "\1"))[0] }}' + +- name: allow the user to use sudo {{"with no password" if test_user_allow_sudo == "nopasswd" else "with a password"}} + copy: + content: | + {{ test_user_name }} ALL=(ALL) {{"NOPASSWD: " if test_user_allow_sudo == "nopasswd" else ""}} ALL + mode: '0440' + dest: '{{ sudoers_path ~ ".d/" ~ test_user_name }}' + notify: delete test user sudoers file diff --git a/tests/e2e/test_become_password_handling.yml b/tests/e2e/test_become_password_handling.yml @@ -0,0 +1,158 @@ +# Adapted from Ansible's test/integration/targets/connection_local/test_become_password_handling.yml + +- hosts: aoh + tasks: + - name: skip this test for non-root users + block: + - debug: + msg: Skipping sudo test for non-root user. + - meta: end_play + when: ansible_facts.user_id != 'root' + + - name: attempt root-to-root sudo become (no-op, should succeed) + vars: + ansible_become: yes + ansible_become_user: root + ansible_become_method: sudo + raw: whoami + register: root_noop_sudo + + - assert: + that: + - root_noop_sudo.stdout is contains "root" + + - name: create an unprivileged become user pair + include_role: + name: setup_become_user_pair + public: true + + - name: deploy sudo shim + copy: + src: sudoshim.sh + dest: /tmp/sudoshim.sh + mode: u+x + + - set_fact: + default_sudo_flags: "{{ lookup('config', 'become_flags', plugin_type='become', plugin_name='sudo') }}" + + - name: apply shared become vars to all tasks that use the sudo test shim + vars: + ansible_become: yes + ansible_become_method: sudo + ansible_become_user: '{{ target_user_name }}' + ansible_become_password: '{{ target_user_password }}' + ansible_become_exe: /tmp/sudoshim.sh + intermediate: "{{ ((' --intermediate-user ' + intermediate_user_name) if intermediate_user_name is defined else '') }}" + block: + - name: verify stdout/stderr noise is present + raw: echo $(whoami) ran + vars: + ansible_become_flags: "{{ default_sudo_flags ~ intermediate ~ ' --inject-stdout-noise --inject-stderr-noise' }}" + ansible_become_password: "{{ intermediate_user_password }}" + register: preamble_visible + + - assert: + that: + - preamble_visible.stdout is contains(target_user_name ~ " ran") + - preamble_visible.stdout is contains "stdout noise" + - preamble_visible.stderr is contains "stderr noise" + + - name: verify sudo succeeds with a password + raw: echo $(whoami) ran + vars: + ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}" + ansible_become_password: "{{ intermediate_user_password }}" + register: success + + - assert: + that: + - success.stdout is contains(target_user_name ~ " ran") + - success.stderr is search 'sudo via ansible.*password\:' + + - name: verify early-closed stderr still sees success + # this test triggers early EOF (which unregisters the associated selector) on most OSs, but not on Ubuntu 24.04 + vars: + ansible_become_flags: --close-stderr --sleep-before-sudo --pretend-to-be-sudo + raw: echo ran_ok + register: stderr_closed + + - assert: + that: + - stderr_closed.stderr is contains "some injected stderr, EOF now" + - stderr_closed.stdout is contains "ran_ok" + + - name: verify timeout handling by setting a sudo prompt that won't trigger password send + vars: + ansible_become_flags: "{{ default_sudo_flags ~ intermediate }} --bogus-prompt" + raw: exit 99 + ignore_errors: true # TODO: Make aoh return AnsibleConnectionFailure on become timeout? + register: prompt_timeout + + - assert: + that: + # - prompt_timeout is unreachable # AOH + - prompt_timeout.msg is contains "Timed out waiting for become success or become password prompt" + - prompt_timeout.msg is contains "BOGUSPROMPT" + - prompt_timeout.rc is not defined + + - name: verify handling of premature exit/stream closure + vars: + ansible_become_exe: /bogus + raw: exit 99 + ignore_errors: true + register: early_close + + - assert: + that: + - early_close is failed + - early_close.msg is contains "Premature end of stream" + + - name: verify lack of required password fails as expected + raw: exit 99 + vars: + ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}" + ansible_become_password: ~ + ignore_errors: true + register: missing_required_password + + - assert: + that: + - missing_required_password is failed + - missing_required_password.msg is contains "password is required" or missing_required_password.msg is contains "interactive authentication is required" + + - name: verify duplicate password prompts are handled (due to incorrect password) + raw: echo hi mom + vars: + ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}" + ansible_become_password: not_the_correct_password + ignore_errors: yes + register: incorrect_password + + - assert: + that: + - incorrect_password is failed + - incorrect_password.msg is contains "Duplicate become password prompt encountered" + + - name: no error, but no become success message + vars: + ansible_become_flags: --pretend-to-be-broken-sudo # handle password prompt, but return no output + raw: exit 99 # should never actually run anyway + ignore_errors: true + register: no_become_success + + - assert: + that: + - no_become_success is failed + - no_become_success.msg is contains "Premature end of stream waiting for become success" + + - name: test broken passwordless sudo + raw: echo hi mom + vars: + ansible_become_flags: --pretend-to-be-broken-passwordless-sudo + ignore_errors: yes + register: broken_passwordless_sudo + + - assert: + that: + - broken_passwordless_sudo is failed + - broken_passwordless_sudo.msg is contains "not a module response" diff --git a/tests/e2e/test_connection.yml b/tests/e2e/test_connection.yml @@ -0,0 +1,77 @@ +# Adapted from Ansible's test/integration/targets/connection/test_connection.yml + +- hosts: aoh + gather_facts: no + serial: 1 + vars: + action_prefix: '' + local_tmp: /tmp/ansible-local + remote_tmp: /tmp/ansible-remote + + tasks: + + # SSH with scp has troubles with using complex filenames that require quoting + # or escaping. The more complex filename scenario is skipped in this mode. + # The default of sftp has no problems with these filenames. + - name: check if ssh with the scp file transfer is being tested + set_fact: + skip_complex_filename: >- + {{ + ansible_connection == "ssh" and + lookup("ansible.builtin.config", + "ssh_transfer_method", + plugin_name=ansible_connection, + plugin_type="connection", + ) == "scp" + }} + echo_string: 汉语 + + - name: set test filename + set_fact: + test_filename: 汉语-{{ skip_complex_filename | ternary("file", "['foo bar']") }}.txt + + ### raw with unicode arg and output + + - name: raw with unicode arg and output + raw: "{{ echo_commands[action_prefix ~ ansible_connection ~ '_' ~ (ansible_shell_type|default(''))] | default(echo_commands['default']) }}" + vars: + # Windows over SSH does not have a way to set the console codepage to allow UTF-8. We need to + # wrap the commands we send to the remote host to get it working. + echo_commands: + win_ssh_powershell: '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo {{ echo_string }}' + win_ssh_cmd: 'powershell.exe -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo {{ echo_string }}"' + default: echo {{ echo_string }} + register: command + - name: check output of raw with unicode arg and output + assert: + that: + - echo_string in command.stdout + - command is changed # as of 2.2, raw should default to changed: true for consistency w/ shell/command/script modules + + ### copy local file with unicode filename and content + + - name: create local file with unicode filename and content + local_action: lineinfile dest={{ local_tmp }}-汉语/{{ test_filename }} create=true line=汉语 + - name: remove remote file with unicode filename and content + action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语/{{ test_filename }} state=absent" + - name: create remote directory with unicode name + action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语 state=directory" + - name: copy local file with unicode filename and content + action: "{{ action_prefix }}copy src={{ local_tmp }}-汉语/{{ test_filename }} dest={{ remote_tmp }}-汉语/{{ test_filename }}" + + ### fetch remote file with unicode filename and content + + - name: remove local file with unicode filename and content + local_action: file path={{ local_tmp }}-汉语/{{ test_filename }} state=absent + - name: fetch remote file with unicode filename and content + fetch: src={{ remote_tmp }}-汉语/{{ test_filename }} dest={{ local_tmp }}-汉语/{{ test_filename }} fail_on_missing=true validate_checksum=true flat=true + + ### remove local and remote temp files + + - name: remove local temp file + local_action: file path={{ local_tmp }}-汉语 state=absent + - name: remove remote temp file + action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语 state=absent" + + ### test wait_for_connection plugin + - wait_for_connection: