mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
e2e: add end-to-end deployment tests (#102)
Add automated deployment testing in Docker that simulates a real system-wide installation: build from source, install replacing GNU shadow-utils, then run 97 assertions covering: - Symlink dispatch and multicall dispatch (all 14 tools) - Setuid bits and non-root privilege enforcement - Full user lifecycle (useradd → chpasswd → usermod → pwck → userdel) - Full group lifecycle (groupadd → groupmod → groupdel → grpck) - Individual tool tests (chage, chfn, chsh, passwd -l/-u) - PAM authentication (su with known password via expect) - nscd cache invalidation - Landlock sandboxing (when kernel supports it) - Ansible integration (user/group modules as real-world consumer) New files: - docker/Dockerfile.e2e — multi-stage build + debian:trixie runtime - tests/e2e/deploy-test.sh — bash test harness with 97 assertions - tests/e2e/ansible-test.yml — Ansible playbook integration test - .dockerignore — prevent sending target/ and .git/ to Docker Modified: - docker-compose.yml — add e2e service (no changes to existing services) - Cargo.toml — add pam feature forwarding to root package - shadow-core/pam.rs — add #[link(name = "pam")] for linker Closes #109, closes #110, closes #111, closes #112, closes #113 Refs #102
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.git/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -110,6 +110,9 @@ feat_phase2 = ["feat_useradd", "feat_userdel", "feat_usermod", "feat_chpasswd",
|
||||
feat_phase3 = ["feat_groupadd", "feat_groupdel", "feat_groupmod", "feat_grpck", "feat_chfn", "feat_chsh", "feat_newgrp"]
|
||||
feat_common = ["feat_phase1", "feat_phase2"]
|
||||
|
||||
# PAM authentication (requires libpam-dev)
|
||||
pam = ["passwd/pam"]
|
||||
|
||||
# Shell completions generator
|
||||
completions = ["dep:clap_complete"]
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
# docker compose run --rm alpine cargo test
|
||||
# docker compose run --rm fedora cargo test
|
||||
# docker compose up --build # build all images
|
||||
# docker compose run --rm e2e # end-to-end deployment tests
|
||||
# docker compose run --rm e2e bash # debug e2e interactively
|
||||
#
|
||||
# The cargo registry cache is shared across Debian/Alpine (same CARGO_HOME path).
|
||||
# Fedora uses a separate cache (rustup installs to /root/.cargo).
|
||||
@@ -47,6 +49,20 @@ services:
|
||||
- cargo-cache-fedora:/root/.cargo/registry
|
||||
- target-fedora:/workspace/target
|
||||
|
||||
# End-to-end deployment test — self-contained, no shared volumes.
|
||||
# Builds shadow-rs from source, installs system-wide, runs ~100 assertions.
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.e2e
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
- "no-new-privileges:false"
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
tmpfs:
|
||||
- /tmp
|
||||
|
||||
volumes:
|
||||
cargo-cache:
|
||||
cargo-cache-fedora:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# shadow-rs end-to-end deployment test image
|
||||
#
|
||||
# Simulates a real deployment: build from source, install system-wide
|
||||
# (replacing GNU shadow-utils), then run ~100 assertions.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose build e2e
|
||||
# docker compose run --rm e2e # run all tests
|
||||
# docker compose run --rm e2e bash # debug interactively
|
||||
|
||||
# ── Stage 1: build shadow-rs ────────────────────────────────────────
|
||||
FROM rust:latest AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpam0g-dev \
|
||||
libselinux1-dev \
|
||||
libaudit-dev \
|
||||
pkg-config \
|
||||
make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --features pam \
|
||||
&& make install DESTDIR=/install PREFIX=/usr
|
||||
|
||||
# ── Stage 2: runtime with tests ────────────────────────────────────
|
||||
FROM debian:trixie
|
||||
|
||||
# Install GNU shadow-utils first — this creates /etc/pam.d/passwd,
|
||||
# /etc/pam.d/su, and other PAM configs we need. We overwrite the
|
||||
# binaries below but keep the PAM configuration intact.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
passwd \
|
||||
login \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Overwrite GNU binaries with shadow-rs
|
||||
COPY --from=builder /install/usr/sbin/ /usr/sbin/
|
||||
|
||||
# Setuid-root — chmod on symlink sets the bit on the target binary
|
||||
RUN chmod 4755 /usr/sbin/shadow-rs
|
||||
|
||||
# Install test dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
expect \
|
||||
nscd \
|
||||
ansible-core \
|
||||
python3-passlib \
|
||||
procps \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root test runner using our own tools
|
||||
RUN /usr/sbin/useradd -m -s /bin/bash testrunner
|
||||
|
||||
# Copy test scripts
|
||||
COPY tests/e2e/ /tests/e2e/
|
||||
RUN chmod +x /tests/e2e/deploy-test.sh
|
||||
|
||||
WORKDIR /tests/e2e
|
||||
CMD ["./deploy-test.sh"]
|
||||
@@ -175,6 +175,7 @@ pub struct PamConv {
|
||||
// PAM FFI function declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[link(name = "pam")]
|
||||
unsafe extern "C" {
|
||||
fn pam_start(
|
||||
service_name: *const libc::c_char,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
# Ansible integration test for shadow-rs
|
||||
#
|
||||
# Validates that shadow-rs tools work as drop-in replacements when
|
||||
# called by Ansible's user/group modules (which invoke useradd,
|
||||
# userdel, usermod, groupadd, groupdel directly as subprocesses).
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -c local -i "localhost," tests/e2e/ansible-test.yml
|
||||
|
||||
- name: shadow-rs integration test
|
||||
hosts: localhost
|
||||
connection: local
|
||||
become: true
|
||||
vars:
|
||||
test_user: ansibleuser
|
||||
test_group: ansiblegroup
|
||||
test_uid: 3000
|
||||
test_gid: 3000
|
||||
test_password: "AnsiblePass123"
|
||||
|
||||
tasks:
|
||||
# ── Create ──────────────────────────────────────────────────────
|
||||
|
||||
- name: Create test group
|
||||
ansible.builtin.group:
|
||||
name: "{{ test_group }}"
|
||||
gid: "{{ test_gid }}"
|
||||
state: present
|
||||
|
||||
- name: Create test user
|
||||
ansible.builtin.user:
|
||||
name: "{{ test_user }}"
|
||||
uid: "{{ test_uid }}"
|
||||
group: "{{ test_group }}"
|
||||
shell: /bin/bash
|
||||
create_home: true
|
||||
comment: "Ansible Test User"
|
||||
|
||||
- name: Verify user exists in passwd
|
||||
ansible.builtin.command: getent passwd {{ test_user }}
|
||||
register: getent_result
|
||||
changed_when: false
|
||||
|
||||
- name: Assert user fields
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "test_user in getent_result.stdout"
|
||||
- "'/bin/bash' in getent_result.stdout"
|
||||
- "test_uid | string in getent_result.stdout"
|
||||
fail_msg: "User {{ test_user }} not created correctly"
|
||||
|
||||
- name: Verify group exists
|
||||
ansible.builtin.command: getent group {{ test_group }}
|
||||
register: group_result
|
||||
changed_when: false
|
||||
|
||||
- name: Assert group fields
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "test_group in group_result.stdout"
|
||||
- "test_gid | string in group_result.stdout"
|
||||
fail_msg: "Group {{ test_group }} not created correctly"
|
||||
|
||||
# ── Modify ──────────────────────────────────────────────────────
|
||||
|
||||
# Set password via chpasswd -e (usermod -p not yet implemented)
|
||||
- name: Set user password via chpasswd
|
||||
ansible.builtin.shell: |
|
||||
echo "{{ test_user }}:$(openssl passwd -6 '{{ test_password }}')" | chpasswd -e
|
||||
changed_when: true
|
||||
|
||||
- name: Modify user shell
|
||||
ansible.builtin.user:
|
||||
name: "{{ test_user }}"
|
||||
shell: /bin/sh
|
||||
|
||||
- name: Verify shell changed
|
||||
ansible.builtin.command: getent passwd {{ test_user }}
|
||||
register: shell_result
|
||||
changed_when: false
|
||||
|
||||
- name: Assert shell is /bin/sh
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'/bin/sh' in shell_result.stdout"
|
||||
fail_msg: "Shell not changed to /bin/sh"
|
||||
|
||||
- name: Add supplementary group
|
||||
ansible.builtin.user:
|
||||
name: "{{ test_user }}"
|
||||
groups: "{{ test_group }}"
|
||||
append: true
|
||||
|
||||
# ── Integrity checks ────────────────────────────────────────────
|
||||
|
||||
- name: Run pwck read-only check
|
||||
ansible.builtin.command: pwck -r
|
||||
register: pwck_result
|
||||
changed_when: false
|
||||
failed_when: pwck_result.rc not in [0, 2]
|
||||
|
||||
- name: Run grpck read-only check
|
||||
ansible.builtin.command: grpck -r
|
||||
register: grpck_result
|
||||
changed_when: false
|
||||
failed_when: grpck_result.rc not in [0, 2]
|
||||
|
||||
# ── Cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
- name: Delete test user
|
||||
ansible.builtin.user:
|
||||
name: "{{ test_user }}"
|
||||
state: absent
|
||||
remove: true
|
||||
|
||||
- name: Delete test group
|
||||
ansible.builtin.group:
|
||||
name: "{{ test_group }}"
|
||||
state: absent
|
||||
|
||||
- name: Verify user deleted
|
||||
ansible.builtin.command: getent passwd {{ test_user }}
|
||||
register: deleted_user
|
||||
failed_when: deleted_user.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: Verify group deleted
|
||||
ansible.builtin.command: getent group {{ test_group }}
|
||||
register: deleted_group
|
||||
failed_when: deleted_group.rc == 0
|
||||
changed_when: false
|
||||
Executable
+494
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env bash
|
||||
# shadow-rs end-to-end deployment tests
|
||||
#
|
||||
# Runs ~100 assertions validating that shadow-rs works as a drop-in
|
||||
# replacement for GNU shadow-utils when installed system-wide.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose run --rm e2e # run all tests
|
||||
# docker compose run --rm e2e bash # debug interactively
|
||||
#
|
||||
# Requires: root (for user/group management), expect, nscd, ansible-core
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── Test framework ──────────────────────────────────────────────────
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SECTION=""
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
section() {
|
||||
SECTION="$1"
|
||||
echo -e "\n${BLUE}── $1 ──${NC}"
|
||||
}
|
||||
|
||||
assert_ok() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $desc (command: $*)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_fail() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo -e " ${RED}✗${NC} $desc (expected failure, got success)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local desc="$1"
|
||||
local pattern="$2"
|
||||
shift 2
|
||||
local output
|
||||
if output=$("$@" 2>&1) && echo "$output" | grep -q "$pattern"; then
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not found in output)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_contains() {
|
||||
local desc="$1"
|
||||
local file="$2"
|
||||
local pattern="$3"
|
||||
if grep -q "$pattern" "$file" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not in $file)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_not_contains() {
|
||||
local desc="$1"
|
||||
local file="$2"
|
||||
local pattern="$3"
|
||||
if grep -q "$pattern" "$file" 2>/dev/null; then
|
||||
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' found in $file but shouldn't be)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper: hash a plaintext password for chpasswd -e
|
||||
hash_password() {
|
||||
openssl passwd -6 "$1"
|
||||
}
|
||||
|
||||
# ── TOOLS list ──────────────────────────────────────────────────────
|
||||
|
||||
TOOLS="passwd pwck useradd userdel usermod chpasswd chage groupadd groupdel groupmod grpck chfn chsh newgrp"
|
||||
SETUID_TOOLS="passwd chfn chsh newgrp"
|
||||
BINDIR="/usr/sbin"
|
||||
|
||||
# ── Preflight ───────────────────────────────────────────────────────
|
||||
|
||||
preflight() {
|
||||
section "Preflight checks"
|
||||
|
||||
assert_ok "shadow-rs binary exists" test -x "$BINDIR/shadow-rs"
|
||||
assert_ok "shadow-rs --list succeeds" "$BINDIR/shadow-rs" --list
|
||||
|
||||
for tool in $TOOLS; do
|
||||
assert_ok "symlink exists: $tool" test -L "$BINDIR/$tool" -o -x "$BINDIR/$tool"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Symlink dispatch ────────────────────────────────────────────────
|
||||
|
||||
test_symlink_dispatch() {
|
||||
section "Symlink dispatch (argv[0])"
|
||||
|
||||
for tool in $TOOLS; do
|
||||
assert_ok "$tool --help via symlink" "$BINDIR/$tool" --help
|
||||
done
|
||||
}
|
||||
|
||||
# ── Multicall dispatch ──────────────────────────────────────────────
|
||||
|
||||
test_multicall_dispatch() {
|
||||
section "Multicall dispatch (shadow-rs <tool>)"
|
||||
|
||||
for tool in $TOOLS; do
|
||||
assert_ok "shadow-rs $tool --help" "$BINDIR/shadow-rs" "$tool" --help
|
||||
done
|
||||
}
|
||||
|
||||
# ── Setuid ──────────────────────────────────────────────────────────
|
||||
|
||||
test_setuid() {
|
||||
section "Setuid bits"
|
||||
|
||||
# Check setuid on the target binary (symlinks always show 777)
|
||||
local target_perms
|
||||
target_perms=$(stat -L -c '%a' "$BINDIR/shadow-rs" 2>/dev/null || echo "0")
|
||||
if [[ "$target_perms" == "4755" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} shadow-rs binary has setuid bit (4755)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} shadow-rs binary expected 4755, got $target_perms"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# Verify each setuid tool symlink resolves to the setuid binary
|
||||
for tool in $SETUID_TOOLS; do
|
||||
local resolved_perms
|
||||
resolved_perms=$(stat -L -c '%a' "$BINDIR/$tool" 2>/dev/null || echo "0")
|
||||
if [[ "$resolved_perms" == "4755" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $tool resolves to setuid binary (4755)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $tool expected 4755 (via symlink), got $resolved_perms"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Non-root user should be able to run passwd -S on themselves
|
||||
assert_ok "testrunner can run passwd -S" \
|
||||
su -s /bin/bash testrunner -c "$BINDIR/passwd -S testrunner"
|
||||
|
||||
# Non-root user should NOT be able to change another user's password
|
||||
assert_fail "testrunner cannot passwd root" \
|
||||
su -s /bin/bash testrunner -c "echo 'root:hacked' | $BINDIR/chpasswd -e"
|
||||
}
|
||||
|
||||
# ── User lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
test_user_lifecycle() {
|
||||
section "User lifecycle (useradd → chpasswd → usermod → pwck → userdel)"
|
||||
|
||||
# Clean up from any previous failed run
|
||||
userdel -r lifecycle_user 2>/dev/null || true
|
||||
groupdel lifecycle_grp 2>/dev/null || true
|
||||
|
||||
# Create user
|
||||
assert_ok "useradd -m -s /bin/bash lifecycle_user" \
|
||||
useradd -m -s /bin/bash lifecycle_user
|
||||
|
||||
assert_file_contains "lifecycle_user in /etc/passwd" \
|
||||
/etc/passwd "^lifecycle_user:"
|
||||
|
||||
assert_file_contains "lifecycle_user in /etc/shadow" \
|
||||
/etc/shadow "^lifecycle_user:"
|
||||
|
||||
assert_ok "home directory created" test -d /home/lifecycle_user
|
||||
|
||||
# Set password via chpasswd -e (pre-hashed)
|
||||
local hashed
|
||||
hashed=$(hash_password "TestPass123")
|
||||
assert_ok "chpasswd -e sets password" \
|
||||
bash -c "echo 'lifecycle_user:$hashed' | chpasswd -e"
|
||||
|
||||
# Verify shadow entry has a hash (not ! or *)
|
||||
assert_file_not_contains "shadow has real hash (not locked)" \
|
||||
/etc/shadow '^lifecycle_user:[!*]:'
|
||||
|
||||
# Modify user
|
||||
assert_ok "usermod -c 'Lifecycle Test' lifecycle_user" \
|
||||
usermod -c "Lifecycle Test" lifecycle_user
|
||||
|
||||
assert_file_contains "GECOS updated" \
|
||||
/etc/passwd "lifecycle_user:.*:Lifecycle Test:"
|
||||
|
||||
assert_ok "usermod -s /bin/sh lifecycle_user" \
|
||||
usermod -s /bin/sh lifecycle_user
|
||||
|
||||
assert_file_contains "shell updated to /bin/sh" \
|
||||
/etc/passwd "lifecycle_user:.*:/bin/sh$"
|
||||
|
||||
# Add supplementary group
|
||||
groupadd lifecycle_grp 2>/dev/null || true
|
||||
assert_ok "usermod -aG lifecycle_grp lifecycle_user" \
|
||||
usermod -aG lifecycle_grp lifecycle_user
|
||||
|
||||
assert_contains "user in supplementary group" "lifecycle_user" \
|
||||
grep "^lifecycle_grp:" /etc/group
|
||||
|
||||
# Consistency check (exit 2 = warnings about system accounts, acceptable)
|
||||
assert_ok "pwck -r passes" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
||||
|
||||
# Lock and unlock password
|
||||
assert_ok "passwd -l lifecycle_user" passwd -l lifecycle_user
|
||||
assert_file_contains "password locked (! prefix)" \
|
||||
/etc/shadow '^lifecycle_user:!'
|
||||
|
||||
assert_ok "passwd -u lifecycle_user" passwd -u lifecycle_user
|
||||
assert_file_not_contains "password unlocked (no ! prefix)" \
|
||||
/etc/shadow '^lifecycle_user:!'
|
||||
|
||||
# Delete user
|
||||
assert_ok "userdel -r lifecycle_user" userdel -r lifecycle_user
|
||||
assert_file_not_contains "user removed from /etc/passwd" \
|
||||
/etc/passwd "^lifecycle_user:"
|
||||
assert_file_not_contains "user removed from /etc/shadow" \
|
||||
/etc/shadow "^lifecycle_user:"
|
||||
|
||||
# Clean up group
|
||||
groupdel lifecycle_grp 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Group lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
test_group_lifecycle() {
|
||||
section "Group lifecycle (groupadd → groupmod → groupdel → grpck)"
|
||||
|
||||
# Clean up from any previous failed run
|
||||
groupdel lifecycle_testgrp 2>/dev/null || true
|
||||
groupdel lifecycle_renamed 2>/dev/null || true
|
||||
|
||||
# Create group
|
||||
assert_ok "groupadd lifecycle_testgrp" groupadd lifecycle_testgrp
|
||||
|
||||
assert_file_contains "group in /etc/group" \
|
||||
/etc/group "^lifecycle_testgrp:"
|
||||
|
||||
# Modify group name
|
||||
assert_ok "groupmod -n lifecycle_renamed lifecycle_testgrp" \
|
||||
groupmod -n lifecycle_renamed lifecycle_testgrp
|
||||
|
||||
assert_file_contains "renamed group in /etc/group" \
|
||||
/etc/group "^lifecycle_renamed:"
|
||||
assert_file_not_contains "old name gone from /etc/group" \
|
||||
/etc/group "^lifecycle_testgrp:"
|
||||
|
||||
# Consistency check
|
||||
assert_ok "grpck -r passes" grpck -r
|
||||
|
||||
# Delete group
|
||||
assert_ok "groupdel lifecycle_renamed" groupdel lifecycle_renamed
|
||||
assert_file_not_contains "group removed from /etc/group" \
|
||||
/etc/group "^lifecycle_renamed:"
|
||||
}
|
||||
|
||||
# ── Individual tool tests ───────────────────────────────────────────
|
||||
|
||||
test_individual_tools() {
|
||||
section "Individual tool tests"
|
||||
|
||||
# Set up test user for tool-specific tests
|
||||
userdel -r tooltest_user 2>/dev/null || true
|
||||
useradd -m -s /bin/bash tooltest_user
|
||||
local hashed
|
||||
hashed=$(hash_password "ToolPass123")
|
||||
bash -c "echo 'tooltest_user:$hashed' | chpasswd -e" || true
|
||||
|
||||
# chage: set and query password aging
|
||||
assert_ok "chage -l tooltest_user" chage -l tooltest_user
|
||||
assert_ok "chage -M 90 tooltest_user" chage -M 90 tooltest_user
|
||||
assert_contains "max days is 90" "90" chage -l tooltest_user
|
||||
|
||||
# chfn: change GECOS
|
||||
assert_ok "chfn -f 'Tool Test User' tooltest_user" \
|
||||
chfn -f "Tool Test User" tooltest_user
|
||||
assert_file_contains "GECOS updated by chfn" \
|
||||
/etc/passwd "tooltest_user:.*:Tool Test User"
|
||||
|
||||
# chsh: change shell
|
||||
assert_ok "chsh -s /bin/sh tooltest_user" chsh -s /bin/sh tooltest_user
|
||||
assert_file_contains "shell changed by chsh" \
|
||||
/etc/passwd "tooltest_user:.*:/bin/sh$"
|
||||
|
||||
# chpasswd -e: batch password change with pre-hashed
|
||||
local newhash
|
||||
newhash=$(hash_password "NewPass456")
|
||||
assert_ok "chpasswd -e batch mode" \
|
||||
bash -c "echo 'tooltest_user:$newhash' | chpasswd -e"
|
||||
|
||||
# passwd -S: status
|
||||
assert_ok "passwd -S tooltest_user" passwd -S tooltest_user
|
||||
|
||||
# pwck/grpck: read-only checks (exit 2 = warnings, acceptable)
|
||||
assert_ok "pwck -r" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
||||
assert_ok "grpck -r" bash -c 'rc=$(grpck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
||||
|
||||
# Clean up
|
||||
userdel -r tooltest_user 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── PAM authentication ──────────────────────────────────────────────
|
||||
|
||||
test_pam_auth() {
|
||||
section "PAM authentication"
|
||||
|
||||
# Create PAM test user with pre-hashed password
|
||||
userdel -r pamtest_user 2>/dev/null || true
|
||||
useradd -m -s /bin/bash pamtest_user
|
||||
local hashed
|
||||
hashed=$(hash_password "PamPass789")
|
||||
bash -c "echo 'pamtest_user:$hashed' | chpasswd -e" || true
|
||||
|
||||
# Use expect to test su authentication
|
||||
assert_ok "su with known password via expect" \
|
||||
expect -c '
|
||||
set timeout 10
|
||||
spawn su -s /bin/bash -c "id" pamtest_user
|
||||
expect "Password:"
|
||||
send "PamPass789\r"
|
||||
expect {
|
||||
"uid=" { exit 0 }
|
||||
timeout { exit 1 }
|
||||
eof { exit 1 }
|
||||
}
|
||||
'
|
||||
|
||||
# Verify the output contains the right user
|
||||
assert_contains "su runs as pamtest_user" "pamtest_user" \
|
||||
expect -c '
|
||||
set timeout 10
|
||||
spawn su -s /bin/bash -c "id" pamtest_user
|
||||
expect "Password:"
|
||||
send "PamPass789\r"
|
||||
expect {
|
||||
"pamtest_user" { exit 0 }
|
||||
timeout { exit 1 }
|
||||
eof { exit 1 }
|
||||
}
|
||||
'
|
||||
|
||||
# Clean up
|
||||
userdel -r pamtest_user 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── nscd cache invalidation ────────────────────────────────────────
|
||||
|
||||
test_nscd() {
|
||||
section "nscd cache invalidation"
|
||||
|
||||
# Start nscd (needs /var/run/nscd directory and /var/db/nscd)
|
||||
mkdir -p /var/run/nscd /var/db/nscd 2>/dev/null || true
|
||||
nscd -d 2>/dev/null &
|
||||
sleep 2
|
||||
|
||||
if ! pgrep -x nscd >/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}⊘${NC} nscd could not be started, skipping"
|
||||
PASS=$((PASS + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
assert_ok "nscd is running" pgrep -x nscd
|
||||
|
||||
# Create user and verify getent picks it up
|
||||
userdel nscd_user 2>/dev/null || true
|
||||
useradd -m nscd_user
|
||||
assert_contains "getent finds new user" "nscd_user" \
|
||||
getent passwd nscd_user
|
||||
|
||||
# Delete user and verify getent no longer finds it
|
||||
userdel -r nscd_user
|
||||
sleep 1
|
||||
assert_fail "getent no longer finds deleted user" \
|
||||
getent passwd nscd_user
|
||||
|
||||
# Stop nscd
|
||||
killall nscd 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Landlock sandboxing ─────────────────────────────────────────────
|
||||
|
||||
test_landlock() {
|
||||
section "Landlock sandboxing"
|
||||
|
||||
# Check if kernel supports Landlock
|
||||
if [ -f /sys/kernel/security/landlock/abi_version ]; then
|
||||
local abi_version
|
||||
abi_version=$(cat /sys/kernel/security/landlock/abi_version)
|
||||
echo -e " ${GREEN}✓${NC} Landlock ABI version: $abi_version"
|
||||
PASS=$((PASS + 1))
|
||||
|
||||
# passwd should work under Landlock restriction
|
||||
userdel -r landlock_user 2>/dev/null || true
|
||||
useradd -m landlock_user
|
||||
|
||||
assert_ok "passwd -S works under Landlock" \
|
||||
passwd -S landlock_user
|
||||
|
||||
userdel -r landlock_user 2>/dev/null || true
|
||||
else
|
||||
echo -e " ${YELLOW}⊘${NC} Landlock not available (kernel too old), skipping"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ansible integration ────────────────────────────────────────────
|
||||
|
||||
test_ansible() {
|
||||
section "Ansible integration"
|
||||
|
||||
if command -v ansible-playbook >/dev/null 2>&1; then
|
||||
# Clean up from any previous failed run
|
||||
userdel -r ansibleuser 2>/dev/null || true
|
||||
groupdel ansiblegroup 2>/dev/null || true
|
||||
|
||||
assert_ok "ansible-playbook runs successfully" \
|
||||
ansible-playbook -c local -i "localhost," /tests/e2e/ansible-test.yml
|
||||
else
|
||||
echo -e " ${YELLOW}⊘${NC} ansible-playbook not found, skipping"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
echo -e "${BLUE}shadow-rs end-to-end deployment tests${NC}"
|
||||
echo "Running as: $(whoami) ($(id -u))"
|
||||
echo "Binary: $BINDIR/shadow-rs"
|
||||
echo ""
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo -e "${RED}ERROR: must run as root${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
preflight
|
||||
test_symlink_dispatch
|
||||
test_multicall_dispatch
|
||||
test_setuid
|
||||
test_user_lifecycle
|
||||
test_group_lifecycle
|
||||
test_individual_tools
|
||||
test_pam_auth
|
||||
test_nscd
|
||||
test_landlock
|
||||
test_ansible
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}── Results ──${NC}"
|
||||
echo -e " ${GREEN}Passed: $PASS${NC}"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo -e " ${RED}Failed: $FAIL${NC}"
|
||||
echo ""
|
||||
echo -e "${RED}SOME TESTS FAILED${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e " ${RED}Failed: 0${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}ALL TESTS PASSED${NC}"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user