mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-15 22:51:37 -07:00
Add unit tests and updated messages
This commit is contained in:
@@ -144,7 +144,6 @@ configure_source() {
|
|||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
sudo chmod 777 /opt/venv/lib/python3.12/site-packages/ && \
|
sudo chmod 777 /opt/venv/lib/python3.12/site-packages/ && \
|
||||||
pip install --no-cache-dir debugpy docker && \
|
|
||||||
sudo chmod 005 /opt/venv/lib/python3.12/site-packages/
|
sudo chmod 005 /opt/venv/lib/python3.12/site-packages/
|
||||||
sudo chmod 666 /var/run/docker.sock
|
sudo chmod 666 /var/run/docker.sock
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,33 @@ printf '
|
|||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
NETALERTX_DOCKER_ERROR_CHECK=0
|
||||||
|
|
||||||
|
|
||||||
# Run all pre-startup checks to validate container environment and dependencies
|
# Run all pre-startup checks to validate container environment and dependencies
|
||||||
|
echo "Startup pre-checks"
|
||||||
for script in ${SYSTEM_SERVICES_SCRIPTS}/check-*.sh; do
|
for script in ${SYSTEM_SERVICES_SCRIPTS}/check-*.sh; do
|
||||||
|
script_name=$(basename "$script" | sed 's/^check-//;s/\.sh$//;s/-/ /g')
|
||||||
|
echo " --> ${script_name}"
|
||||||
|
|
||||||
sh "$script"
|
sh "$script"
|
||||||
|
NETALERTX_DOCKER_ERROR_CHECK=$?
|
||||||
|
|
||||||
|
if [ ${NETALERTX_DOCKER_ERROR_CHECK} -ne 0 ]; then
|
||||||
|
|
||||||
|
echo exit code ${NETALERTX_DOCKER_ERROR_CHECK} from ${script}
|
||||||
|
exit ${NETALERTX_DOCKER_ERROR_CHECK}
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Exit after checks if in check-only mode (for testing)
|
||||||
|
if [ "${NETALERTX_CHECK_ONLY:-0}" -eq 1 ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Update vendor data (MAC address OUI database) in the background
|
# Update vendor data (MAC address OUI database) in the background
|
||||||
# This happens concurrently with service startup to avoid blocking container readiness
|
# This happens concurrently with service startup to avoid blocking container readiness
|
||||||
${SYSTEM_SERVICES_SCRIPTS}/update_vendors.sh &
|
bash ${SYSTEM_SERVICES_SCRIPTS}/update_vendors.sh &
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
-1
@@ -105,6 +105,5 @@ if [ "$failures" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Permission checks passed successfully."
|
|
||||||
|
|
||||||
|
|
||||||
@@ -27,5 +27,6 @@ then
|
|||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
EOF
|
EOF
|
||||||
>&2 printf "%s" "${RESET}"
|
>&2 printf "%s" "${RESET}"
|
||||||
exit 1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
exit 0
|
||||||
@@ -5,16 +5,14 @@
|
|||||||
if [ ! -f ${NETALERTX_CONFIG}/app.conf ]; then
|
if [ ! -f ${NETALERTX_CONFIG}/app.conf ]; then
|
||||||
mkdir -p "${NETALERTX_CONFIG}" || {
|
mkdir -p "${NETALERTX_CONFIG}" || {
|
||||||
>&2 echo "ERROR: Failed to create config directory ${NETALERTX_CONFIG}"
|
>&2 echo "ERROR: Failed to create config directory ${NETALERTX_CONFIG}"
|
||||||
exit 1
|
exit 0
|
||||||
}
|
}
|
||||||
cp /app/back/app.conf "${NETALERTX_CONFIG}/app.conf" || {
|
cp /app/back/app.conf "${NETALERTX_CONFIG}/app.conf" || {
|
||||||
>&2 echo "ERROR: Failed to copy default config to ${NETALERTX_CONFIG}/app.conf"
|
>&2 echo "ERROR: Failed to copy default config to ${NETALERTX_CONFIG}/app.conf"
|
||||||
exit 1
|
exit 0
|
||||||
}
|
}
|
||||||
CYAN='\033[1;36m'
|
|
||||||
RESET='\033[0m'
|
RESET='\033[0m'
|
||||||
>&2 printf "%s" "${CYAN}"
|
>&2 cat <<'EOF'
|
||||||
>&2 cat <<EOF
|
|
||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
🆕 First run detected. Default configuration written to ${NETALERTX_CONFIG}/app.conf.
|
🆕 First run detected. Default configuration written to ${NETALERTX_CONFIG}/app.conf.
|
||||||
|
|
||||||
@@ -22,6 +20,7 @@ if [ ! -f ${NETALERTX_CONFIG}/app.conf ]; then
|
|||||||
this instance in production.
|
this instance in production.
|
||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
EOF
|
EOF
|
||||||
>&2 printf "%s" "${RESET}"
|
|
||||||
|
>&2 printf "%s" "${RESET}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ CREATE TABLE IF NOT EXISTS "Online_History" (
|
|||||||
"Offline_Devices" INTEGER,
|
"Offline_Devices" INTEGER,
|
||||||
PRIMARY KEY("Index" AUTOINCREMENT)
|
PRIMARY KEY("Index" AUTOINCREMENT)
|
||||||
);
|
);
|
||||||
CREATE TABLE sqlite_sequence(name,seq);
|
|
||||||
CREATE TABLE Devices (
|
CREATE TABLE Devices (
|
||||||
devMac STRING (50) PRIMARY KEY NOT NULL COLLATE NOCASE,
|
devMac STRING (50) PRIMARY KEY NOT NULL COLLATE NOCASE,
|
||||||
devName STRING (50) NOT NULL DEFAULT "(unknown)",
|
devName STRING (50) NOT NULL DEFAULT "(unknown)",
|
||||||
|
|||||||
@@ -1,9 +1,53 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Initialize required directories and log files
|
# Initialize required directories and log files
|
||||||
# These must exist before services start to avoid permission/write errors
|
# These must exist before services start to avoid permission/write errors
|
||||||
# TODO - improve with per-directory warning if creation fails
|
|
||||||
[ ! -d "${NETALERTX_PLUGINS_LOG}" ] && mkdir -p "${NETALERTX_PLUGINS_LOG}"
|
check_mandatory_folders() {
|
||||||
[ ! -d "${SYSTEM_SERVICES_RUN_LOG}" ] && mkdir -p "${SYSTEM_SERVICES_RUN_LOG}"
|
# Check and create plugins log directory
|
||||||
[ ! -d "${SYSTEM_SERVICES_RUN_TMP}" ] && mkdir -p "${SYSTEM_SERVICES_RUN_TMP}"
|
if [ ! -d "${NETALERTX_PLUGINS_LOG}" ]; then
|
||||||
[ ! -f "${LOG_DB_IS_LOCKED}" ] && touch "${LOG_DB_IS_LOCKED}"
|
echo "Warning: Plugins log directory missing, creating..."
|
||||||
[ ! -f "${LOG_EXECUTION_QUEUE}" ] && touch "${LOG_EXECUTION_QUEUE}"
|
if ! mkdir -p "${NETALERTX_PLUGINS_LOG}"; then
|
||||||
|
echo "Error: Failed to create plugins log directory: ${NETALERTX_PLUGINS_LOG}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check and create system services run log directory
|
||||||
|
if [ ! -d "${SYSTEM_SERVICES_RUN_LOG}" ]; then
|
||||||
|
echo "Warning: System services run log directory missing, creating..."
|
||||||
|
if ! mkdir -p "${SYSTEM_SERVICES_RUN_LOG}"; then
|
||||||
|
echo "Error: Failed to create system services run log directory: ${SYSTEM_SERVICES_RUN_LOG}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check and create system services run tmp directory
|
||||||
|
if [ ! -d "${SYSTEM_SERVICES_RUN_TMP}" ]; then
|
||||||
|
echo "Warning: System services run tmp directory missing, creating..."
|
||||||
|
if ! mkdir -p "${SYSTEM_SERVICES_RUN_TMP}"; then
|
||||||
|
echo "Error: Failed to create system services run tmp directory: ${SYSTEM_SERVICES_RUN_TMP}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check and create DB locked log file
|
||||||
|
if [ ! -f "${LOG_DB_IS_LOCKED}" ]; then
|
||||||
|
echo "Warning: DB locked log file missing, creating..."
|
||||||
|
if ! touch "${LOG_DB_IS_LOCKED}"; then
|
||||||
|
echo "Error: Failed to create DB locked log file: ${LOG_DB_IS_LOCKED}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check and create execution queue log file
|
||||||
|
if [ ! -f "${LOG_EXECUTION_QUEUE}" ]; then
|
||||||
|
echo "Warning: Execution queue log file missing, creating..."
|
||||||
|
if ! touch "${LOG_EXECUTION_QUEUE}"; then
|
||||||
|
echo "Error: Failed to create execution queue log file: ${LOG_EXECUTION_QUEUE}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
check_mandatory_folders
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# check-network-mode.sh - detect when the container is not using host networking.
|
# check-network-mode.sh - detect when the container is not using host networking.
|
||||||
|
|
||||||
|
# Exit if NETALERTX_DEBUG=1
|
||||||
|
if [ "${NETALERTX_DEBUG}" = "1" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the default network interface
|
||||||
DEFAULT_IF="$(ip route show default 0.0.0.0/0 2>/dev/null | awk 'NR==1 {print $5}')"
|
DEFAULT_IF="$(ip route show default 0.0.0.0/0 2>/dev/null | awk 'NR==1 {print $5}')"
|
||||||
if [ -z "${DEFAULT_IF}" ]; then
|
if [ -z "${DEFAULT_IF}" ]; then
|
||||||
# No default route; nothing to validate.
|
# No default route; nothing to validate.
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
IF_LINK_INFO="$(ip link show "${DEFAULT_IF}" 2>/dev/null)"
|
IF_LINK_INFO="$(ip link show "${DEFAULT_IF}" 2>/dev/null)"
|
||||||
IF_IP="$(ip -4 addr show "${DEFAULT_IF}" 2>/dev/null | awk '/inet / {print $2}' | head -n1)"
|
IF_IP="$(ip -4 addr show "${DEFAULT_IF}" 2>/dev/null | awk '/inet / {print $2}' | head -n1)"
|
||||||
IF_MAC=""
|
IF_MAC=""
|
||||||
@@ -16,12 +23,14 @@ fi
|
|||||||
|
|
||||||
looks_like_bridge="0"
|
looks_like_bridge="0"
|
||||||
|
|
||||||
|
# Check for common bridge MAC and IP patterns
|
||||||
case "${IF_MAC}" in
|
case "${IF_MAC}" in
|
||||||
02:42:*) looks_like_bridge="1" ;;
|
02:42:*) looks_like_bridge="1" ;;
|
||||||
00:00:00:00:00:00) looks_like_bridge="1" ;;
|
00:00:00:00:00:00) looks_like_bridge="1" ;;
|
||||||
"") ;; # leave as is
|
"") ;; # leave as is
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Check for common bridge IP ranges
|
||||||
case "${IF_IP}" in
|
case "${IF_IP}" in
|
||||||
172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) looks_like_bridge="1" ;;
|
172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) looks_like_bridge="1" ;;
|
||||||
192.168.65.*) looks_like_bridge="1" ;;
|
192.168.65.*) looks_like_bridge="1" ;;
|
||||||
@@ -52,4 +61,4 @@ RESET=$(printf '\033[0m')
|
|||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
EOF
|
EOF
|
||||||
>&2 printf "%s" "${RESET}"
|
>&2 printf "%s" "${RESET}"
|
||||||
exit 1
|
exit 0
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ warn_if_not_dedicated_mount "${NETALERTX_API}"
|
|||||||
warn_if_not_dedicated_mount "${NETALERTX_LOG}"
|
warn_if_not_dedicated_mount "${NETALERTX_LOG}"
|
||||||
|
|
||||||
if [ "${failures}" -ne 0 ]; then
|
if [ "${failures}" -ne 0 ]; then
|
||||||
exit 1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "${SYSTEM_NGINX_CONFIG}/conf.active" ]; then
|
if [ ! -f "${SYSTEM_NGINX_CONFIG}/conf.active" ]; then
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ if [ "${CURRENT_UID}" -eq 0 ]; then
|
|||||||
* Keep the default USER in the image (20211:20211), or
|
* Keep the default USER in the image (20211:20211), or
|
||||||
* In docker-compose.yml, remove any 'user:' override that sets UID 0.
|
* In docker-compose.yml, remove any 'user:' override that sets UID 0.
|
||||||
|
|
||||||
|
Note: As a courtesy, this special mode is only used to set the permissions
|
||||||
|
of /app/db and /app/config to be owned by the netalertx user so future
|
||||||
|
runs work correctly.
|
||||||
|
|
||||||
Bottom line: never run security tooling as root unless you are actively
|
Bottom line: never run security tooling as root unless you are actively
|
||||||
trying to get pwned.
|
trying to get pwned.
|
||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
EOF
|
EOF
|
||||||
>&2 printf "%s" "${RESET}"
|
>&2 printf "%s" "${RESET}"
|
||||||
|
sleep 5 # Give user time to read the message
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
@@ -39,4 +39,5 @@ RESET=$(printf '\033[0m')
|
|||||||
══════════════════════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════════════════════
|
||||||
EOF
|
EOF
|
||||||
>&2 printf "%s" "${RESET}"
|
>&2 printf "%s" "${RESET}"
|
||||||
exit 1
|
sleep 5 # Give user time to read the message
|
||||||
|
exit 0
|
||||||
|
|||||||
+5
-1
@@ -2,4 +2,8 @@
|
|||||||
python_classes = ["Test", "Describe"]
|
python_classes = ["Test", "Describe"]
|
||||||
python_functions = ["test_", "it_", "and_", "but_", "they_"]
|
python_functions = ["test_", "it_", "and_", "but_", "they_"]
|
||||||
python_files = ["test_*.py",]
|
python_files = ["test_*.py",]
|
||||||
testpaths = ["test",]
|
testpaths = ["test", "tests/docker_tests"]
|
||||||
|
markers = [
|
||||||
|
"docker: requires docker socket and elevated container permissions",
|
||||||
|
"feature_complete: extended coverage suite not run by default",
|
||||||
|
]
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Alpine Docker tests
|
|
||||||
|
|
||||||
This is intended to be run as Root user as permissions are altered. It will create and analyze the results of various configurations on containers. The test craeates a container, logs the results, terminates the container, then starts the next test
|
|
||||||
0. No errors on startup
|
|
||||||
1. missing config/db generation
|
|
||||||
2. After config/db generation
|
|
||||||
1. root user mount on
|
|
||||||
1. /app/db
|
|
||||||
2. /app/config
|
|
||||||
3. /app/log
|
|
||||||
4. /app/api
|
|
||||||
5. /services/config/nginx/conf.active
|
|
||||||
6. /services/run/
|
|
||||||
2. 000 permissions on
|
|
||||||
1. /app/db
|
|
||||||
2. /app/db/app.db
|
|
||||||
3. /app/config
|
|
||||||
4. /app/config/app.conf
|
|
||||||
5. /app/log
|
|
||||||
6. /app/api
|
|
||||||
7. /services/config/nginx/conf.active
|
|
||||||
8. /services/run/
|
|
||||||
3. Container read-only missing mounts
|
|
||||||
1. /app/db
|
|
||||||
2. /app/config
|
|
||||||
3. /app/log
|
|
||||||
4. /app/api
|
|
||||||
5. /services/config/nginx/conf.active
|
|
||||||
6. /services/run/
|
|
||||||
4. Custom port/listen address without /services/config/nginx/conf.active mount
|
|
||||||
5. Missing cap NET_ADMIN, NET_RAW, NET_BIND_SERVICE
|
|
||||||
6. Run as Root user
|
|
||||||
7. Run as user 1000
|
|
||||||
8. Run without network_mode host
|
|
||||||
9. Missing /app/config/app.conf
|
|
||||||
10. Missing /app/db/app.db
|
|
||||||
11. Ramdisk mounted on
|
|
||||||
1. /app/config
|
|
||||||
2. /app/db
|
|
||||||
@@ -0,0 +1,951 @@
|
|||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
#TODO: test ALWAYS_FRESH_INSTALL
|
||||||
|
#TODO: test new named volume mount
|
||||||
|
|
||||||
|
IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test")
|
||||||
|
GRACE_SECONDS = float(os.environ.get("NETALERTX_TEST_GRACE", "2"))
|
||||||
|
DEFAULT_CAPS = ["NET_RAW", "NET_ADMIN", "NET_BIND_SERVICE"]
|
||||||
|
|
||||||
|
VOLUME_MAP = {
|
||||||
|
"app_db": "/app/db",
|
||||||
|
"app_config": "/app/config",
|
||||||
|
"app_log": "/app/log",
|
||||||
|
"app_api": "/app/api",
|
||||||
|
"nginx_conf": "/services/config/nginx/conf.active",
|
||||||
|
"services_run": "/services/run",
|
||||||
|
}
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.docker, pytest.mark.feature_complete]
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_label(prefix: str) -> str:
|
||||||
|
return f"{prefix.upper()}__NETALERTX_INTENTIONAL__{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _create_docker_volume(prefix: str) -> str:
|
||||||
|
name = f"netalertx-test-{prefix}-{uuid.uuid4().hex[:8]}".lower()
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "volume", "create", name],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_docker_volume(name: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "volume", "rm", "-f", name],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chown_path(host_path: pathlib.Path, uid: int, gid: int) -> None:
|
||||||
|
"""Chown a host path using the test image with host user namespace."""
|
||||||
|
if not host_path.exists():
|
||||||
|
raise RuntimeError(f"Cannot chown missing path {host_path}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--userns",
|
||||||
|
"host",
|
||||||
|
"--user",
|
||||||
|
"0:0",
|
||||||
|
"--entrypoint",
|
||||||
|
"/bin/chown",
|
||||||
|
"-v",
|
||||||
|
f"{host_path}:/mnt",
|
||||||
|
IMAGE,
|
||||||
|
"-R",
|
||||||
|
f"{uid}:{gid}",
|
||||||
|
"/mnt",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise RuntimeError(f"Failed to chown {host_path} to {uid}:{gid}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_mount_tree(tmp_path: pathlib.Path, prefix: str, seed_config: bool = True, seed_db: bool = True) -> dict[str, pathlib.Path]:
|
||||||
|
label = _unique_label(prefix)
|
||||||
|
base = tmp_path / f"{label}_MOUNT_ROOT"
|
||||||
|
base.mkdir()
|
||||||
|
paths: dict[str, pathlib.Path] = {}
|
||||||
|
|
||||||
|
for key, target in VOLUME_MAP.items():
|
||||||
|
folder_name = f"{label}_{key.upper()}_INTENTIONAL_NETALERTX_TEST"
|
||||||
|
host_path = base / folder_name
|
||||||
|
host_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Make the directory writable so the container (running as UID 20211)
|
||||||
|
# can create files on first run even if the host owner differs.
|
||||||
|
try:
|
||||||
|
host_path.chmod(0o777)
|
||||||
|
except PermissionError:
|
||||||
|
# If we can't chmod (uncommon in CI), tests that require strict
|
||||||
|
# ownership will still run their own chown/chmod operations.
|
||||||
|
pass
|
||||||
|
paths[key] = host_path
|
||||||
|
|
||||||
|
if seed_config:
|
||||||
|
config_file = paths["app_config"] / "app.conf"
|
||||||
|
shutil.copyfile(
|
||||||
|
"/workspaces/NetAlertX/back/app.conf",
|
||||||
|
config_file,
|
||||||
|
)
|
||||||
|
config_file.chmod(0o600)
|
||||||
|
if seed_db:
|
||||||
|
db_file = paths["app_db"] / "app.db"
|
||||||
|
shutil.copyfile(
|
||||||
|
"/workspaces/NetAlertX/db/app.db",
|
||||||
|
db_file,
|
||||||
|
)
|
||||||
|
db_file.chmod(0o600)
|
||||||
|
|
||||||
|
_chown_netalertx(base)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_fixed_mount_tree(base: pathlib.Path) -> dict[str, pathlib.Path]:
|
||||||
|
if base.exists():
|
||||||
|
shutil.rmtree(base)
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
|
||||||
|
paths: dict[str, pathlib.Path] = {}
|
||||||
|
for key in VOLUME_MAP:
|
||||||
|
host_path = base / f"{key.upper()}_NETALERTX_TEST"
|
||||||
|
host_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
host_path.chmod(0o777)
|
||||||
|
paths[key] = host_path
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _build_volume_args(
|
||||||
|
paths: dict[str, pathlib.Path],
|
||||||
|
read_only: set[str] | None = None,
|
||||||
|
skip: set[str] | None = None,
|
||||||
|
) -> list[tuple[str, str, bool]]:
|
||||||
|
bindings: list[tuple[str, str, bool]] = []
|
||||||
|
for key, target in VOLUME_MAP.items():
|
||||||
|
if skip and key in skip:
|
||||||
|
continue
|
||||||
|
bindings.append((str(paths[key]), target, key in read_only if read_only else False))
|
||||||
|
return bindings
|
||||||
|
|
||||||
|
|
||||||
|
def _chown_root(host_path: pathlib.Path) -> None:
|
||||||
|
_chown_path(host_path, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _chown_netalertx(host_path: pathlib.Path) -> None:
|
||||||
|
_chown_path(host_path, 20211, 20211)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_container(
|
||||||
|
label: str,
|
||||||
|
volumes: list[tuple[str, str, bool]] | None = None,
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
drop_caps: list[str] | None = None,
|
||||||
|
network_mode: str | None = "host",
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
|
volume_specs: list[str] | None = None,
|
||||||
|
sleep_seconds: float = GRACE_SECONDS,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
name = f"netalertx-test-{label}-{uuid.uuid4().hex[:8]}".lower()
|
||||||
|
cmd: list[str] = ["docker", "run", "--rm", "--name", name]
|
||||||
|
|
||||||
|
if network_mode:
|
||||||
|
cmd.extend(["--network", network_mode])
|
||||||
|
cmd.extend(["--userns", "host"])
|
||||||
|
if user:
|
||||||
|
cmd.extend(["--user", user])
|
||||||
|
if drop_caps:
|
||||||
|
for cap in drop_caps:
|
||||||
|
cmd.extend(["--cap-drop", cap])
|
||||||
|
else:
|
||||||
|
for cap in DEFAULT_CAPS:
|
||||||
|
cmd.extend(["--cap-add", cap])
|
||||||
|
if env:
|
||||||
|
for key, value in env.items():
|
||||||
|
cmd.extend(["-e", f"{key}={value}"])
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
for host_path, target, readonly in volumes or []:
|
||||||
|
mount = f"{host_path}:{target}"
|
||||||
|
if readonly:
|
||||||
|
mount += ":ro"
|
||||||
|
cmd.extend(["-v", mount])
|
||||||
|
if volume_specs:
|
||||||
|
for spec in volume_specs:
|
||||||
|
cmd.extend(["-v", spec])
|
||||||
|
|
||||||
|
# Diagnostic wrapper: list ownership and perms of mounted targets inside
|
||||||
|
# the container before running the real entrypoint. This helps debug
|
||||||
|
# permission failures by capturing the container's view of the host mounts.
|
||||||
|
mounts_ls = """
|
||||||
|
echo "--- MOUNT PERMS (container view) ---";
|
||||||
|
ls -ldn \
|
||||||
|
"""
|
||||||
|
for _, target, _ in volumes or []:
|
||||||
|
mounts_ls += f" {target}"
|
||||||
|
mounts_ls += " || true; echo '--- END MOUNTS ---'; \n"
|
||||||
|
|
||||||
|
script = (
|
||||||
|
mounts_ls
|
||||||
|
+ f"sh /entrypoint.sh & pid=$!; "
|
||||||
|
+ f"sleep {sleep_seconds}; "
|
||||||
|
+ "if kill -0 $pid >/dev/null 2>&1; then kill -TERM $pid >/dev/null 2>&1 || true; fi; "
|
||||||
|
+ "wait $pid; code=$?; if [ $code -eq 143 ]; then exit 0; fi; exit $code"
|
||||||
|
)
|
||||||
|
cmd.extend(["--entrypoint", "/bin/sh", IMAGE, "-c", script])
|
||||||
|
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
timeout=sleep_seconds + 30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_contains(output: str, snippet: str) -> None:
|
||||||
|
import re
|
||||||
|
stripped = re.sub(r'\x1b\[[0-9;]*m', '', output)
|
||||||
|
assert snippet in stripped, f"Expected to find '{snippet}' in container output.\nGot:\n{stripped}"
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_zero_perm_dir(paths: dict[str, pathlib.Path], key: str) -> None:
|
||||||
|
"""Set up a directory with files and zero permissions for testing."""
|
||||||
|
if key in ["app_db", "app_config"]:
|
||||||
|
# Files already exist from _setup_mount_tree seeding
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Create a dummy file for other directories
|
||||||
|
(paths[key] / "dummy.txt").write_text("dummy")
|
||||||
|
|
||||||
|
# Chmod all files in the directory to 000
|
||||||
|
for f in paths[key].iterdir():
|
||||||
|
f.chmod(0)
|
||||||
|
|
||||||
|
# Chmod the directory itself to 000
|
||||||
|
paths[key].chmod(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_zero_perm_dir(paths: dict[str, pathlib.Path], key: str) -> None:
|
||||||
|
"""Restore permissions after zero perm test."""
|
||||||
|
# Chmod directory back to 700
|
||||||
|
paths[key].chmod(0o700)
|
||||||
|
|
||||||
|
# Chmod files back to appropriate permissions
|
||||||
|
for f in paths[key].iterdir():
|
||||||
|
if f.name in ["app.db", "app.conf"]:
|
||||||
|
f.chmod(0o600)
|
||||||
|
else:
|
||||||
|
f.chmod(0o644)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_run_creates_config_and_db(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test that containers start successfully with proper configuration.
|
||||||
|
|
||||||
|
0.1 Missing config/db generation: First run creates default app.conf and app.db
|
||||||
|
This test validates that on the first run with empty mount directories,
|
||||||
|
the container automatically generates default configuration and database files.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "first_run_missing", seed_config=False, seed_db=False)
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
# In some CI/devcontainer environments the bind mounts are visible as
|
||||||
|
# root-owned inside the container due to user namespace or mount behaviour.
|
||||||
|
# Allow the container to run as root for the initial-seed test so it can
|
||||||
|
# write default config and build the DB. This keeps the test stable.
|
||||||
|
result = _run_container("first-run-missing", volumes, user="0:0")
|
||||||
|
_assert_contains(result.stdout, "Default configuration written to")
|
||||||
|
_assert_contains(result.stdout, "Building initial database schema")
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_run_starts_clean() -> None:
|
||||||
|
"""Test that containers start successfully with proper configuration.
|
||||||
|
|
||||||
|
0.2 After config/db generation: Subsequent runs start cleanly with existing files
|
||||||
|
This test validates that after initial configuration and database files exist,
|
||||||
|
the container starts cleanly without regenerating defaults.
|
||||||
|
"""
|
||||||
|
base = pathlib.Path("/tmp/NETALERTX_SECOND_RUN_CLEAN_TEST_MOUNT_INTENTIONAL")
|
||||||
|
paths = _setup_fixed_mount_tree(base)
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copyfile("/workspaces/NetAlertX/back/app.conf", paths["app_config"] / "app.conf")
|
||||||
|
shutil.copyfile("/workspaces/NetAlertX/db/app.db", paths["app_db"] / "app.db")
|
||||||
|
(paths["app_config"] / "app.conf").chmod(0o600)
|
||||||
|
(paths["app_db"] / "app.db").chmod(0o600)
|
||||||
|
|
||||||
|
second = _run_container("second-run", volumes, user="0:0", sleep_seconds=3)
|
||||||
|
assert "Default configuration written" not in second.stdout
|
||||||
|
assert "Building initial database schema" not in second.stdout
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(base, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_app_db_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
|
||||||
|
Check script: check-app-permissions.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: Write permission denied. The application cannot write to..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_app_db")
|
||||||
|
_chown_root(paths["app_db"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-app-db", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_db"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["app_db"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_app_config_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_app_config")
|
||||||
|
_chown_root(paths["app_config"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-app-config", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_config"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["app_config"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_app_log_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_app_log")
|
||||||
|
_chown_root(paths["app_log"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-app-log", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_log"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["app_log"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_app_api_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_app_api")
|
||||||
|
_chown_root(paths["app_api"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-app-api", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_api"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["app_api"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_nginx_conf_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_nginx_conf")
|
||||||
|
_chown_root(paths["nginx_conf"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-nginx-conf", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["nginx_conf"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["nginx_conf"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_owned_services_run_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test root-owned mounts - simulates mounting host directories owned by root.
|
||||||
|
|
||||||
|
1. Root-Owned Mounts: Simulates mounting host directories owned by root
|
||||||
|
(common with docker run -v /host/path:/app/db).
|
||||||
|
Tests each required mount point when owned by root user.
|
||||||
|
Expected: Warning about permission issues, guidance to fix ownership.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "root_services_run")
|
||||||
|
_chown_root(paths["services_run"])
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("root-services-run", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["services_run"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_chown_netalertx(paths["services_run"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_db_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
|
||||||
|
Check script: check-app-permissions.sh
|
||||||
|
Sample messages: "⚠️ ATTENTION: Write permission denied. The application cannot write to..."
|
||||||
|
"⚠️ ATTENTION: Read permission denied. The application cannot read from..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_db")
|
||||||
|
_setup_zero_perm_dir(paths, "app_db")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-db", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_db"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "app_db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_db_file(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_db_file")
|
||||||
|
(paths["app_db"] / "app.db").chmod(0)
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-db-file", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
(paths["app_db"] / "app.db").chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_config_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_config")
|
||||||
|
_setup_zero_perm_dir(paths, "app_config")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-config", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_config"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "app_config")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_config_file(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_config_file")
|
||||||
|
(paths["app_config"] / "app.conf").chmod(0)
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-config-file", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
(paths["app_config"] / "app.conf").chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_log_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_log")
|
||||||
|
_setup_zero_perm_dir(paths, "app_log")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-log", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_log"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "app_log")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_app_api_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_app_api")
|
||||||
|
_setup_zero_perm_dir(paths, "app_api")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-app-api", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_api"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "app_api")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_nginx_conf_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_nginx_conf")
|
||||||
|
_setup_zero_perm_dir(paths, "nginx_conf")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-nginx-conf", volumes, user="20211:20211")
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "nginx_conf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_permissions_services_run_dir(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test zero permissions - simulates mounting directories/files with no permissions.
|
||||||
|
|
||||||
|
2. Zero Permissions: Simulates mounting directories/files with no permissions (chmod 000).
|
||||||
|
Tests directories and files with no read/write/execute permissions.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to fix permissions.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "chmod_services_run")
|
||||||
|
_setup_zero_perm_dir(paths, "services_run")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("chmod-services-run", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["services_run"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "services_run")
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_app_db_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_app_db")
|
||||||
|
volumes = _build_volume_args(paths, read_only={"app_db"})
|
||||||
|
result = _run_container("readonly-app-db", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_db"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_app_config_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_app_config")
|
||||||
|
volumes = _build_volume_args(paths, read_only={"app_config"})
|
||||||
|
result = _run_container("readonly-app-config", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_config"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_app_log_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_app_log")
|
||||||
|
volumes = _build_volume_args(paths, read_only={"app_log"})
|
||||||
|
result = _run_container("readonly-app-log", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_log"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_app_api_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_app_api")
|
||||||
|
volumes = _build_volume_args(paths, read_only={"app_api"})
|
||||||
|
result = _run_container("readonly-app-api", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["app_api"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_nginx_conf_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_nginx_conf")
|
||||||
|
_setup_zero_perm_dir(paths, "nginx_conf")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container("readonly-nginx-conf", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/services/config/nginx/conf.active")
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
_restore_zero_perm_dir(paths, "nginx_conf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_services_run_mount(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test readonly mounts - simulates read-only volume mounts in containers.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when mounted read-only.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "readonly_services_run")
|
||||||
|
volumes = _build_volume_args(paths, read_only={"services_run"})
|
||||||
|
result = _run_container("readonly-services-run", volumes)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, str(VOLUME_MAP["services_run"]))
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_port_without_writable_conf(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test custom port configuration without writable nginx config mount.
|
||||||
|
|
||||||
|
4. Custom Port Without Nginx Config Mount: Simulates setting custom LISTEN_ADDR/PORT
|
||||||
|
without mounting nginx config. Container starts but uses default address.
|
||||||
|
Expected: Container starts but uses default address, warning about missing config mount.
|
||||||
|
|
||||||
|
Check script: check-nginx-config.sh
|
||||||
|
Sample messages: "⚠️ ATTENTION: Nginx configuration mount /services/config/nginx/conf.active is missing."
|
||||||
|
"⚠️ ATTENTION: Unable to write to /services/config/nginx/conf.active/netalertx.conf."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "custom_port_ro_conf")
|
||||||
|
paths["nginx_conf"].chmod(0o500)
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
try:
|
||||||
|
result = _run_container(
|
||||||
|
"custom-port-ro-conf",
|
||||||
|
volumes,
|
||||||
|
env={"PORT": "24444", "LISTEN_ADDR": "127.0.0.1"},
|
||||||
|
)
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/services/config/nginx/conf.active")
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
paths["nginx_conf"].chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_app_db(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
|
||||||
|
Check scripts: check-storage.sh, check-storage-extra.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: /app/db is not a persistent mount. Your data in this directory..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_app_db")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_db"})
|
||||||
|
result = _run_container("missing-mount-app-db", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/api")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_app_config(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_app_config")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_config"})
|
||||||
|
result = _run_container("missing-mount-app-config", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/api")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_app_log(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_app_log")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_log"})
|
||||||
|
result = _run_container("missing-mount-app-log", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/api")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_app_api(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_app_api")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_api"})
|
||||||
|
result = _run_container("missing-mount-app-api", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/config")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_nginx_conf(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_nginx_conf")
|
||||||
|
volumes = _build_volume_args(paths, skip={"nginx_conf"})
|
||||||
|
result = _run_container("missing-mount-nginx-conf", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/api")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_mount_services_run(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required mounts - simulates forgetting to mount persistent volumes.
|
||||||
|
|
||||||
|
3. Missing Required Mounts: Simulates forgetting to mount required persistent volumes
|
||||||
|
in read-only containers. Tests each required mount point when missing.
|
||||||
|
Expected: "Write permission denied" error with path, guidance to add volume mounts.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_mount_services_run")
|
||||||
|
volumes = _build_volume_args(paths, skip={"services_run"})
|
||||||
|
result = _run_container("missing-mount-services-run", volumes, user="20211:20211")
|
||||||
|
_assert_contains(result.stdout, "Write permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/api")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_capabilities_triggers_warning(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing required capabilities - simulates insufficient container privileges.
|
||||||
|
|
||||||
|
5. Missing Required Capabilities: Simulates running without NET_ADMIN, NET_RAW,
|
||||||
|
NET_BIND_SERVICE capabilities. Required for ARP scanning and network operations.
|
||||||
|
Expected: "exec /bin/sh: operation not permitted" error, guidance to add capabilities.
|
||||||
|
|
||||||
|
Check script: check-cap.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: Raw network capabilities are missing. Tools that rely on NET_RAW..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_caps")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container(
|
||||||
|
"missing-caps",
|
||||||
|
volumes,
|
||||||
|
drop_caps=["ALL"],
|
||||||
|
)
|
||||||
|
_assert_contains(result.stdout, "exec /bin/sh: operation not permitted")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_as_root_is_blocked(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test running as root user - simulates insecure container execution.
|
||||||
|
|
||||||
|
6. Running as Root User: Simulates running container as root (UID 0) instead of
|
||||||
|
dedicated netalertx user. Warning about security risks, special permission fix mode.
|
||||||
|
Expected: Warning about security risks, guidance to use UID 20211.
|
||||||
|
|
||||||
|
Check script: check-root.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: NetAlertX is running as root (UID 0). This defeats every hardening..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "run_as_root")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container(
|
||||||
|
"run-as-root",
|
||||||
|
volumes,
|
||||||
|
user="0:0",
|
||||||
|
)
|
||||||
|
_assert_contains(result.stdout, "NetAlertX is running as root")
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_as_uid_1000_warns(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test running as wrong user - simulates using arbitrary user instead of netalertx.
|
||||||
|
|
||||||
|
7. Running as Wrong User: Simulates running as arbitrary user (UID 1000) instead
|
||||||
|
of netalertx user. Permission errors due to incorrect user context.
|
||||||
|
Expected: Permission errors, guidance to use correct user.
|
||||||
|
|
||||||
|
Check script: check-user-netalertx.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: NetAlertX is running as UID 1000:1000. Hardened permissions..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "run_as_1000")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container(
|
||||||
|
"run-as-1000",
|
||||||
|
volumes,
|
||||||
|
user="1000:1000",
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_host_network_warns(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing host networking - simulates running without host network mode.
|
||||||
|
|
||||||
|
8. Missing Host Networking: Simulates running without network_mode: host.
|
||||||
|
Limits ARP scanning capabilities for network discovery.
|
||||||
|
Expected: Warning about ARP scanning limitations, guidance to use host networking.
|
||||||
|
|
||||||
|
Check script: check-network-mode.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: NetAlertX is not running with --network=host. Bridge networking..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_host_net")
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container(
|
||||||
|
"missing-host-network",
|
||||||
|
volumes,
|
||||||
|
network_mode=None,
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_app_conf_triggers_seed(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing configuration file seeding - simulates corrupted/missing app.conf.
|
||||||
|
|
||||||
|
9. Missing Configuration File: Simulates corrupted/missing app.conf.
|
||||||
|
Container automatically regenerates default configuration on startup.
|
||||||
|
Expected: Automatic regeneration of default configuration.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_app_conf")
|
||||||
|
(paths["app_config"] / "app.conf").unlink()
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container("missing-app-conf", volumes, user="0:0")
|
||||||
|
_assert_contains(result.stdout, "Default configuration written to")
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_app_db_triggers_seed(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test missing database file seeding - simulates corrupted/missing app.db.
|
||||||
|
|
||||||
|
10. Missing Database File: Simulates corrupted/missing app.db.
|
||||||
|
Container automatically creates initial database schema on startup.
|
||||||
|
Expected: Automatic creation of initial database schema.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "missing_app_db")
|
||||||
|
(paths["app_db"] / "app.db").unlink()
|
||||||
|
volumes = _build_volume_args(paths)
|
||||||
|
result = _run_container("missing-app-db", volumes, user="0:0")
|
||||||
|
_assert_contains(result.stdout, "Building initial database schema")
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmpfs_config_mount_warns(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test tmpfs instead of volumes - simulates using tmpfs for persistent data.
|
||||||
|
|
||||||
|
11. Tmpfs Instead of Volumes: Simulates using tmpfs mounts instead of persistent volumes
|
||||||
|
(data loss on restart). Tests config and db directories mounted as tmpfs.
|
||||||
|
Expected: "Read permission denied" error, guidance to use persistent volumes.
|
||||||
|
|
||||||
|
Check scripts: check-storage.sh, check-storage-extra.sh
|
||||||
|
Sample message: "⚠️ ATTENTION: /app/config is not a persistent mount. Your data in this directory..."
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "tmpfs_config")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_config"})
|
||||||
|
extra = ["--mount", "type=tmpfs,destination=/app/config"]
|
||||||
|
result = _run_container(
|
||||||
|
"tmpfs-config",
|
||||||
|
volumes,
|
||||||
|
extra_args=extra,
|
||||||
|
)
|
||||||
|
_assert_contains(result.stdout, "Read permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/config")
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmpfs_db_mount_warns(tmp_path: pathlib.Path) -> None:
|
||||||
|
"""Test tmpfs instead of volumes - simulates using tmpfs for persistent data.
|
||||||
|
|
||||||
|
11. Tmpfs Instead of Volumes: Simulates using tmpfs mounts instead of persistent volumes
|
||||||
|
(data loss on restart). Tests config and db directories mounted as tmpfs.
|
||||||
|
Expected: "Read permission denied" error, guidance to use persistent volumes.
|
||||||
|
"""
|
||||||
|
paths = _setup_mount_tree(tmp_path, "tmpfs_db")
|
||||||
|
volumes = _build_volume_args(paths, skip={"app_db"})
|
||||||
|
extra = ["--mount", "type=tmpfs,destination=/app/db"]
|
||||||
|
result = _run_container(
|
||||||
|
"tmpfs-db",
|
||||||
|
volumes,
|
||||||
|
extra_args=extra,
|
||||||
|
)
|
||||||
|
_assert_contains(result.stdout, "Read permission denied")
|
||||||
|
_assert_contains(result.stdout, "/app/db")
|
||||||
|
assert result.returncode != 0
|
||||||
Reference in New Issue
Block a user