#!/usr/bin/bash

# Simple test wrapper around radclient to allow automated UATs
#
# Author Arran Cudbard-Bell <a.cudbardb@freeradius.org>
# Copyright 2014-2015,2025-2026 Arran Cudbard-Bell <a.cudbardb@freeradius.org>
# Copyright 2015 The FreeRADIUS Project

# A POSIX variable
OPTIND=1         # Reset in case getopts has been used previously in the shell.

# Environmental variables
: ${TESTDIR=$(dirname "$0")"/tests"}
: ${RADCLIENT='radclient'}
: ${FILTER_SUFFIX='_expected'}
# What we send back in response to a CoA request
: ${COA_REPLY_SUFFIX='_coa_reply'}
# What we expect from a CoA request
: ${COA_FILTER_SUFFIX='_coa_expected'}
# File to run before running tests
: ${PRE_FILE='pre'}
# File to run after running tests
: ${POST_FILE='post'}

PATH="$(dirname "$0")/bin:${PATH}"

# Initialize our own variables
verbose=0
cluster=
role=
type=
skip_tests_not_found=0
host_split_chars='.'
parallel=40
retries=3
timeout=2
target_host="$(hostname -f)"
secret='testing123'

# Initialise now for scoping
test_path=
packets=
filters=

# Some very basic logging functions
function ERROR
{
    echo "$@" 1>&2
}

function INFO
{
    echo "$@"
}

function DEBUG
{
    if [ $verbose -gt 0 ]; then
        echo "$@"
    fi
}

#
# Escape characters for use inside a sed bracket expression
#
function char_class_escape
{
    local s="$1"
    s=${s//\\/\\\\}   # backslash
    s=${s//]/\\]}     # closing bracket
    s=${s//-/\\-}     # dash
    s=${s//^/\\^}     # caret
    s=${s//./\\.}     # dot (explicitly required)
    echo "$s"
}

#
#  Execute the post file after tests have completed, or the script exits
#
#  $1: if set to 1, then if the post file fails, exit with the return code.
#      if set to 0, ignore the failure
#
function post_run
{
    rm -f -- "$packets" "$filters" >/dev/null 2>&1

    if [ -n "${test_path}" ] && [ -f "${test_path}/${POST_FILE}" ]; then
        file_path="${test_path}/${POST_FILE}"

        DEBUG "Executing post-file: ${file_path}"
        "${file_path}"
        ret=$?
        if [ ${1-0} -eq 1 ] && [ $ret -ne 0 ]; then
            ERROR "Post-file failed with ${ret}"
            exit ${ret}
        fi
    fi
}

#
#  Execute the "pre" file to setup any pre-requisites for the tests for
#  this particular cluster/role or set of tests.
#
function pre_run
{
    trap post_run EXIT INT TERM

    if [ -f "${test_path}/${PRE_FILE}" ]; then
        file_path="${test_path}/${PRE_FILE}"

        DEBUG "Executing pre-file: ${file_path}"
        "${file_path}"
        ret=$?
        if [ $ret -ne 0 ]; then
            ERROR "Pre-file failed with ${ret}"
            exit ${ret}
        fi
    fi
}

function usage
{
    echo $(basename "$0")" [options] [-- <test_glob0> <test_glob1> <test_globN>]"
    echo "  -h                        Display this help message."
    echo "  -H <host>[:port]          Send test packets to specified host and port (defaults to ${target_host})"
    echo "  -v                        Verbose mode."
    echo "  -p <number>               Run tests in parallel (defaults to ${parallel})."
    echo "  -d <chars>                Characters to split FQDN on for cluster and type matching (defaults to '.')."
    echo "  -s <secret>               Shared secret."
    if [ ${#role_types[@]} -gt 0 ]; then
        echo "  -c <cluster>              Specify cluster type one of ($cluster_types)."
        echo "  -r <type>                 Specify server role one of (${role_types[@]})."
        echo "  -S                        Skip host if we can't find a matching cluster or role".
        echo
        echo "Note: Test path globs are relative to ${TESTDIR}/<cluster>/<type>/"
    fi

    echo
    echo "For role based test file layout create test files under ${TESTDIR}/<cluster>/<type>"
    echo "Where <cluster> and <type> are substrings (exploded with -d) found in the FQDN of <host>."
    echo "For simplified test layout create test files under ${TESTDIR}"
    echo
    echo "The directory containing the tests should contains pairs of request files and filter files."
    echo "The request file name must contain 'test<num><num><num>."
    echo "The filter name must match the test name but with a '${FILTER_SUFFIX}' suffix."
    echo
    echo "For example:"
    echo "  ${TESTDIR}/test000_my_first_test"
    echo "  ${TESTDIR}/test000_my_first_test${FILTER_SUFFIX}"
    echo
    echo "It is also possible to specify a CoA reply and filter file."
    echo "The CoA-Request must be sent to port 3799 on the machine raduat is executing on."
    echo "The contents of the CoA reply file will be sent back to the RADIUS server."
    echo "An optional CoA filter file can be specified to check the contents of the CoA request."
    echo
    echo "The CoA reply file name must match the test name but with a '${COA_REPLY_SUFFIX}' suffix."
    echo "The CoA filter file name must match the test name but with a '${COA_FILTER_SUFFIX}' suffix."
    echo "For example:"
    echo "  ${TESTDIR}/test000_my_first_test${COA_REPLY_SUFFIX}"
    echo "  ${TESTDIR}/test000_my_first_test${COA_FILTER_SUFFIX}"
    echo
    echo "The following types of tests will be executed serially:"
    echo "  - Tests without an expected response"
    echo "  - Tests with CoA replies"
    echo "  - Tests with CoA filters"
    echo "  - Tests with \"# serial\" in the first line of the test file"
    echo
    echo "The directory containing the tests may have multiple subdirectories to group the tests."
}

RADCLIENT="$(command -v -- "$RADCLIENT" || true)"
if [ ! -x "$RADCLIENT" ]; then
    ERROR "Can't find radclient binary, modify your \$PATH or set RADCLIENT"
    exit 64
fi

if [ ! -d "$TESTDIR" ]; then
    ERROR "Test dir $TESTDIR does not exist, create it or specify it with TESTDIR=<dir>"
    usage
    exit 64
fi

# Definitions (build these dynamically by looking at the files under tests)
cluster_types=($(find "$TESTDIR/" -mindepth 1 -maxdepth 1 -type d -exec basename {} \;))

role_types=()
for i in "${cluster_types[@]}"; do
    role_types+=($(find "$TESTDIR/${i}/" -mindepth 1 -maxdepth 1 -type d  -exec basename {} \;))
done

if [ ${#role_types[@]} -eq 0 ]; then
    DEBUG "Using simple test file layout"
else
    DEBUG "Using role based test file layout"
    role_types=($(echo "${role_types[@]}" | sort -u))
fi

while getopts "h?H:vd:c:r:s:Sp:" opt; do
    case "$opt" in
    h|\?)
        usage
        exit 0
        ;;

    v)
        verbose=1
        ;;

    d)
        host_split_chars="$OPTARG"
        ;;

    c)
        found=0
        for i in "${cluster_types[@]}"; do
            if [ "$i" == "$OPTARG" ]; then
                found=1
            fi
        done
        if [ $found -ne 1 ]; then
            ERROR "'$OPTARG' is not a valid cluster type"
            usage
            exit 64
        fi
        cluster="$OPTARG"
        ;;

    r)
        found=0
        for i in "${role_types[@]}"; do
            if [ "$i" == "$OPTARG" ]; then
                found=1
            fi
        done
        if [ $found -ne 1 ]; then
            ERROR "'$OPTARG' is not a valid role type"
            usage
            exit 64
        fi
        role="$OPTARG"
        ;;

    s)
        secret="$OPTARG"
        ;;

    S)
        skip_tests_not_found=1
        ;;

    p)
        if ! echo "$OPTARG" | grep -qE '^[0-9]+$'; then
            ERROR "Non integer argument '$OPTARG' specified for -p"
            usage
            exit 64
        fi
        parallel=$OPTARG
        ;;

    H)
        target_host="$OPTARG"
        ;;

    esac
done

shift $((OPTIND-1))

[ "$1" = "--" ] && shift
test_globs=("$@")

#
#  Match keywords from the hostname to clusters or roles
#
if [ ${#role_types[@]} -gt 0 ]; then
    for tok in ${target_host//[$(char_class_escape "$host_split_chars")]/ }; do
        if [ -z "${cluster}" ]; then
            for key in "${cluster_types[@]}"; do
                grep -qF -- "${key}" <<<"${tok}" && cluster="${key}" && break
            done
        fi
        if [ -z "${role}" ]; then
            for key in "${role_types[@]}"; do
                grep -qF -- "${key}" <<<"${tok}" && role="${key}" && break
            done
        fi
        [ -n "${cluster}" ] && [ -n "${role}" ] && break
    done

    if [ $skip_tests_not_found -eq 1 ] && [ -z "$cluster" ] || [ -z "$role" ] ; then
        DEBUG "Skipping tests for host $target_host: couldn't determine cluster or role"
        exit 0
    fi

    if [ -z "$cluster" ]; then
        ERROR "Couldn't determine the cluster $target_host belongs to";
        usage
        exit 64;
    fi

    if [ -z "$role" ]; then
        ERROR "Couldn't determine the role $target_host performs";
        usage
        exit 64;
    fi

    test_path="${TESTDIR}/${cluster}/${role}"
#
#  Otherwise just use the tests in the test dir
#
else
    test_path="${TESTDIR}"
fi

# Allow test files with spaces in them, and only split on newlines
old_ifs=$IFS
IFS=$'\n'
set -f  # Disable globbing
test_files=()
if [ ${#test_globs[@]} -gt 0 ]; then
    for glob in $test_globs; do
        # Filter out response files (makes wildcards easier), and expand the globs
        for file in $(find "${test_path}" -depth -path "*${glob}" \
            -and -not -path "*${FILTER_SUFFIX}" \
            -and -not -path "*${COA_REPLY_SUFFIX}" \
            -and -not -path "*${COA_FILTER_SUFFIX}" \
            -and -not -path "${PRE_FILE}" \
            -and -not -path "${POST_FILE}" \
            -and '(' -type f -or -type l ')'); do
            test_files+=("$file")
        done
    done
else
    # Lexicographical, depth-first
    test_files=($(find "$test_path" -depth -path '*test[0-9][0-9][0-9]*' \
                -and -not -path "*${FILTER_SUFFIX}" \
                -and -not -path "*${COA_REPLY_SUFFIX}" \
                -and -not -path "*${COA_FILTER_SUFFIX}" \
                -and -not -path "${PRE_FILE}" \
                -and -not -path "${POST_FILE}" \
                -and '(' -type f -or -type l ')'))
    if [ "${#test_files[@]}" -eq 0 ]; then
        ERROR "No test files found in $test_path"
        exit 64;
    fi
    INFO "Executing ${#test_files[@]} test(s) from ${test_path}"
fi
IFS=$old_ifs
set +f  # Re-enable globbing

#
#  Check if we got any test files
#
if [ "${#test_files[@]}" -eq 0 ]; then
    ERROR "No test files to process"
    exit 1
fi

#
#  Output which files were going to be using for testing
#
if [ $verbose -eq 0 ]; then
    INFO "Executing specified tests"
    INFO "Use -v to see full list"
else
    INFO "Executing specified tests:"
    for i in "${test_files[@]}"; do
        DEBUG "$i"
    done
fi

#
#  Figure out which tests we can munge into a single file which we can
#  use to parallelise testing
#
base=$(basename "$0")
packets=$(mktemp -t "${base}XXX") || exit 1
filters=$(mktemp -t "${base}XXX") || exit 1

#
#  Run any pre-test setup, and setup cleanup handlers
#
pre_run

args=
file_args=
serial_file_args=
for i in "${test_files[@]}"; do
    if [ ! -f "$i" ] && [ ! -L "$i" ]; then
        INFO "Skipping $i: not file"
        continue
    fi

    if [ ! -r "$i" ]; then
        INFO "Skipping $i: not readable (check permissions)"
        continue
    fi

    expected="${i}${FILTER_SUFFIX}"
    # We need pairs of requests and responses in the bulk format
    if [ ! -f "$expected" ] && [ ! -L "$expected" ]; then
        DEBUG "$i cannot be parallelised: Can't find 'expected' file"
        file_args+=" -f \"$i\""
        continue
    fi

    # We mostly do this to let radclient produce an error
    if [ ! -r "$expected" ]; then
        INFO "$i cannot be parallelised: 'expected' file not readable"
        file_args+=" -f \"${i}:${expected}\""
        continue
    fi

    # If there's a coa_reply file, then we need to serialise the test
    # because there's no bulk test format that includes coa_reply
    coa_reply="${i}${COA_REPLY_SUFFIX}"
    if [ -f "$coa_reply" ] || [ -L "$coa_reply" ]; then
        DEBUG "$i cannot be parallelised: Found CoA Reply"

        # We also have a filter
        coa_filter="${i}${COA_FILTER_SUFFIX}"
        if [ -f "$coa_filter" ] || [ -L "$coa_filter" ]; then
            serial_file_args+=" -f \"${i}:${expected}:${coa_reply}:${coa_filter}\""
        else
            serial_file_args+=" -f \"${i}:${expected}:${coa_reply}\""
        fi
        continue
    fi

    # If the test file is marked as serial only, then we need to serialise the test
    if head -n 1 "$i" | grep -qiE '^#\s*serial'; then
        DEBUG "$i marked as serial only"
        serial_file_args+=" -f \"${i}:${expected}\""
        continue
    fi

    # Else add it to the master test file
    printf '%s\n' "$(cat "$i")" >> "$packets"

    # Add the name of the file so it appears in radclient debug output
    # and can later be specified with -v -- <test> to drill down.
    echo "Radclient-Test-Name := \""$(echo "$i" | sed -e "s@${test_path}/\?@@")"\"" >> "$packets"
    echo >> "$packets"
    printf '%s\n' "$(cat "${expected}")" >> "$filters"
    echo >> "$filters"
done

if [ `cat "$packets" | wc -l` -gt 0 ]; then
    file_args+=" -f \"${packets}:${filters}\""
fi

if [ ! -z "$file_args" ]; then
	args="$file_args"

	if [ $verbose -ne 0 ]; then
    		args+=" -x"
	fi

	args+=" -s"
	args+=" -t \"$timeout\""
	args+=" -r \"$retries\""
	args+=" -p \"$parallel\""
	args+=" \"$target_host\""
	args+=" auto"
	args+=" \"$secret\""

	DEBUG "Executing: $RADCLIENT $args"
	eval $RADCLIENT $args; ret=$?
	INFO "(Parallelised tests)"
	INFO ""

	rm -f "$packets" >/dev/null 2>&1
	rm -f "$filters" >/dev/null 2>&1

	if [ $ret -ne 0 ]; then
    		ERROR "One or more tests failed (radclient exited with $ret)"
    		exit $ret
	fi
fi

if [ ! -z "$serial_file_args" ]; then
	args="$serial_file_args"

	if [ $verbose -ne 0 ]; then
    		args+=" -x"
	fi

	args+=" -s"
	args+=" -t \"$timeout\""
	args+=" -r \"$retries\""
	args+=" -p 1"
	args+=" \"$target_host\""
	args+=" auto"
	args+=" \"$secret\""

	DEBUG "Executing: $RADCLIENT $args"
	eval $RADCLIENT $args; ret=$?
	INFO "(Serialised tests)"

	if [ $ret -ne 0 ]; then
    		ERROR "One or more tests failed (radclient exited with $ret)"
    		exit $ret
	fi
fi

exit 0
