#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" export LANQIN_SOURCE_ONLY=true # shellcheck source=install.sh source "${ROOT_DIR}/install.sh" fail_test() { printf 'FAIL: %s\n' "$*" >&2 exit 1 } assert_eq() { local want="$1" got="$2" label="$3" [[ "${got}" == "${want}" ]] || fail_test "${label}: got '${got}', want '${want}'" } test_hostname_validation() { valid_hostname "mail.example.com" || fail_test "valid hostname rejected" valid_hostname "mx-1.example.co.uk" || fail_test "valid multi-label hostname rejected" ! valid_hostname "mail_example.com" || fail_test "hostname with underscore accepted" ! valid_hostname "localhost" || fail_test "single-label hostname accepted" ! valid_hostname "-mail.example.com" || fail_test "hostname with leading hyphen accepted" } test_password_validation() { LANQIN_ADMIN_PASSWORD="abc123" assert_eq "abc123" "$(prompt_admin_password)" "six-character password" if (LANQIN_ADMIN_PASSWORD="abc12" prompt_admin_password >/dev/null 2>&1); then fail_test "five-character password accepted" fi if (LANQIN_ADMIN_PASSWORD="abc\$123" prompt_admin_password >/dev/null 2>&1); then fail_test "unsafe env-file password accepted" fi if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then fail_test "password beginning with an env-file comment marker accepted" fi LANQIN_RESET_PASSWORD="reset1" assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password" if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then fail_test "five-character reset password accepted" fi } test_mail_domain_and_admin_email_validation() { assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion" assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion" assert_eq "newszxcn.com" "$(suggest_mail_domain "mail.newszxcn.com")" "NewSzxcn mail host domain suggestion" assert_eq "admin@newszxcn.com" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.com")" "NewSzxcn default administrator email" assert_eq "newszxcn.cm" "$(suggest_mail_domain "mail.newszxcn.cm")" "two-label suffix mail host domain suggestion" assert_eq "admin@newszxcn.cm" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.cm")" "matching administrator email for entered domain" LANQIN_MAIL_DOMAIN="example.com" LANQIN_ADMIN_EMAIL="admin@example.com" assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain" assert_eq "admin@example.com" "$(prompt_admin_email "example.com")" "explicit administrator email" if (LANQIN_ADMIN_EMAIL="admin@other.example.com" prompt_admin_email "example.com" >/dev/null 2>&1); then fail_test "administrator email outside mail domain accepted" fi if (has_tty() { return 1; }; unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL; prompt_mail_domain "mail.example.com" >/dev/null 2>&1); then fail_test "noninteractive mail domain guessed without explicit input" fi unset LANQIN_MAIL_DOMAIN LANQIN_ADMIN_EMAIL } test_install_configuration() { local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5" local temp_dir temp_dir="$(mktemp -d)" cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env.example" export INSTALL_DIR="${temp_dir}" export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}" export LANQIN_PUBLIC_HOSTNAME="mail.example.com" export LANQIN_MAIL_DOMAIN="example.com" export LANQIN_ADMIN_EMAIL="admin@example.com" export LANQIN_ADMIN_PASSWORD="abc123" export LANQIN_INSTALL_WEB_MODE="${web_mode}" configure_first_install configure_runtime_bindings assert_eq "${firewall_mode}" "$(env_value LANQIN_INSTALL_FIREWALL_MODE)" "firewall mode" assert_eq "${web_mode}" "$(env_value LANQIN_INSTALL_WEB_MODE)" "web mode" assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind" assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL" assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag" assert_eq "example.com" "$(env_value LANQIN_MAIL_DOMAIN)" "mail address domain" assert_eq "admin@example.com" "$(env_value LANQIN_ADMIN_EMAIL)" "administrator email" assert_eq "admin" "$(env_value LANQIN_ADMIN_USERNAME)" "legacy administrator username prefix" assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password" } test_nginx_configuration() { local temp_dir old_path temp_dir="$(mktemp -d)" old_path="${PATH}" mkdir -p "${temp_dir}/bin" "${temp_dir}/install" "${temp_dir}/certs" "${temp_dir}/acme" printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/nginx" printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/systemctl" chmod 0755 "${temp_dir}/bin/nginx" "${temp_dir}/bin/systemctl" cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/install/.env" export PATH="${temp_dir}/bin:${PATH}" INSTALL_DIR="${temp_dir}/install" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" ACME_WEBROOT="${temp_dir}/acme" CERT_DIR="${temp_dir}/certs" set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com" write_nginx_http_config grep -Fq 'proxy_pass http://127.0.0.1:8088;' "${NGINX_CONFIG}" || fail_test "HTTP proxy target missing" grep -Fq 'root '"${ACME_WEBROOT}"';' "${NGINX_CONFIG}" || fail_test "ACME webroot missing" write_nginx_https_config grep -Fq 'listen 443 ssl http2;' "${NGINX_CONFIG}" || fail_test "HTTPS listener missing" # shellcheck disable=SC2016 grep -Fq 'return 301 https://$host$request_uri;' "${NGINX_CONFIG}" || fail_test "HTTPS redirect missing" grep -Fq "ssl_certificate ${CERT_DIR}/fullchain.pem;" "${NGINX_CONFIG}" || fail_test "certificate path missing" PATH="${old_path}" } test_compose_configuration() { # shellcheck disable=SC2016 grep -Fq '${LANQIN_HTTP_BIND:-80}:80' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "HTTP port mapping missing" ! grep -Fq 'LANQIN_HTTPS_BIND' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "dead container HTTPS mapping remains" grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing" } test_legacy_configuration_is_preserved() { local temp_dir temp_dir="$(mktemp -d)" cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env" export INSTALL_DIR="${temp_dir}" set_env LANQIN_INSTALL_WEB_MODE "" set_env LANQIN_HTTP_BIND "127.0.0.1:9090" configure_first_install configure_runtime_bindings assert_eq "127.0.0.1:9090" "$(env_value LANQIN_HTTP_BIND)" "legacy HTTP bind" } test_menu_choice() { export LANQIN_MENU_ACTION=0 assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action" export LANQIN_MENU_ACTION=1 assert_eq "1" "$(prompt_menu_choice 2)" "menu install action" export LANQIN_MENU_ACTION=12 assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action" if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then fail_test "out-of-range menu action accepted" fi unset LANQIN_MENU_ACTION } test_menu_rendering() ( local output prompt_text() { printf '%b' "$1"; } output="$(render_uninstalled_menu)" [[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing" [[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing" [[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing" [[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing" [[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing" [[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action" [[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action" output="$(render_installed_menu "运行中" "v1.2.19" "https://mail.example.com")" for expected in \ '状态:运行中' \ '版本:v1.2.19' \ '地址:https://mail.example.com' \ '安装与维护' \ '服务管理' \ '证书与恢复' \ '账号与帮助' \ '危险操作' \ '9. 邮箱后台配置指南' \ '12. 卸载服务(保留数据)'; do [[ "${output}" == *"${expected}"* ]] || fail_test "installed menu item missing: ${expected}" done ) test_menu_dispatch() ( local temp_dir action_file LANQIN_MENU_ACTION=1 temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" action_file="${temp_dir}/action" mkdir -p "${INSTALL_DIR}" prompt_text() { :; } do_install() { printf 'install\n' > "${action_file}"; } do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; } do_menu grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install" LANQIN_MENU_ACTION=2 do_menu grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore" LANQIN_MENU_ACTION=3 do_menu >/dev/null printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" menu_service_status() { printf '运行中'; } menu_installed_version() { printf 'v1.2.19'; } do_update() { printf 'update\n' > "${action_file}"; } LANQIN_MENU_ACTION=2 do_menu grep -Fq 'update' "${action_file}" || fail_test "installed menu did not dispatch update" unset LANQIN_MENU_ACTION ) test_menu_runtime_metadata() ( local temp_dir running="true" image_version="v1.2.19" temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" mkdir -p "${INSTALL_DIR}" printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" compose() { if [[ "$*" == 'ps -q lanqin-email' ]]; then printf 'container-id\n' fi } current_image_id() { printf 'sha256:test-image\n'; } docker() { if [[ "$*" == 'compose version' ]]; then return 0 fi if [[ "$*" == *'.State.Running'* ]]; then printf '%s\n' "${running}" return 0 fi if [[ "$*" == *'org.opencontainers.image.version'* ]]; then printf '%s\n' "${image_version}" fi } assert_eq "运行中" "$(menu_service_status)" "running menu service status" assert_eq "v1.2.19" "$(menu_installed_version)" "installed menu version" running="false" assert_eq "已停止" "$(menu_service_status)" "stopped menu service status" image_version="" assert_eq "未知" "$(menu_installed_version)" "missing image version label" ) test_incomplete_install_defaults_to_repair() ( local temp_dir action_file LANQIN_MENU_ACTION=3 temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" action_file="${temp_dir}/action" mkdir -p "${INSTALL_DIR}" printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" prompt_text() { :; } menu_installed_version() { printf '未知'; } do_repair_install() { printf 'repair\n' > "${action_file}"; } do_menu grep -Fq 'repair' "${action_file}" || fail_test "incomplete installation did not dispatch repair" unset LANQIN_MENU_ACTION ) test_service_commands_require_complete_installation() ( local temp_dir command_name temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" mkdir -p "${INSTALL_DIR}" # Invoked indirectly by the service command functions under test. # shellcheck disable=SC2317,SC2329 ensure_docker() { fail_test "service command checked Docker before installation"; } for command_name in do_update do_status do_logs do_restart do_certificate do_rollback do_reset_admin_password do_reset_admin_two_factor do_uninstall; do if ("${command_name}" >/dev/null 2>&1); then fail_test "${command_name} accepted missing installation" fi done ) test_admin_credentials() ( local temp_dir output temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" mkdir -p "${INSTALL_DIR}" cat > "${INSTALL_DIR}/.env" <<'EOF' LANQIN_PUBLIC_BASE_URL=https://mail.example.com LANQIN_MAIL_DOMAIN=example.com LANQIN_ADMIN_EMAIL=admin@example.com LANQIN_ADMIN_PASSWORD=recorded-password EOF output="$(do_show_admin_credentials 2>&1)" [[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing" [[ "${output}" == *'管理员邮箱:admin@example.com'* ]] || fail_test "administrator email missing" [[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing" [[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing" ) test_admin_password_hash_parsing() ( compose() { # shellcheck disable=SC2016 printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n' } # shellcheck disable=SC2016 assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing" ) test_admin_password_reset_only_updates_admin_account() ( local temp_dir compose_calls backup_path temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" compose_calls="${temp_dir}/compose-calls" mkdir -p "${INSTALL_DIR}/data/backups" cat > "${INSTALL_DIR}/.env" <<'EOF' LANQIN_PUBLIC_HOSTNAME=mail.example.com LANQIN_MAIL_DOMAIN=example.com LANQIN_ADMIN_EMAIL=admin@example.com LANQIN_ADMIN_PASSWORD=old-password EOF printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" ensure_docker() { return 0; } current_image_id() { printf 'sha256:test-image\n'; } backup_database() { backup_path="$1" printf 'backup\n' > "${backup_path}" } prompt_reset_password() { printf 'new-password'; } # shellcheck disable=SC2016 generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; } compose() { printf '%s\n' "$*" >> "${compose_calls}" if [[ "$*" == *'SELECT id FROM users'* ]]; then printf 'admin-user-id\n' elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then printf 'user=1\nmailboxes=2\n' fi } do_reset_admin_password >/dev/null assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password" [[ -s "${backup_path}" ]] || fail_test "password reset database backup missing" grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not email and role restricted" grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated" grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized" grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator" ) test_admin_two_factor_reset_only_updates_admin_account() ( local temp_dir compose_calls backup_path temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" compose_calls="${temp_dir}/compose-calls" mkdir -p "${INSTALL_DIR}/data/backups" cat > "${INSTALL_DIR}/.env" <<'EOF' LANQIN_PUBLIC_HOSTNAME=mail.example.com LANQIN_MAIL_DOMAIN=example.com LANQIN_ADMIN_EMAIL=admin@example.com EOF printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" ensure_docker() { return 0; } current_image_id() { printf 'sha256:test-image\n'; } backup_database() { backup_path="$1" printf 'backup\n' > "${backup_path}" } compose() { printf '%s\n' "$*" >> "${compose_calls}" if [[ "$*" == *'SELECT id FROM users'* ]]; then printf 'admin-user-id\n' elif [[ "$*" == *"UPDATE users SET two_factor_secret=''"* ]]; then printf 'user=1\nrecovery=2\nchallenges=1\n' fi } do_reset_admin_two_factor >/dev/null [[ -s "${backup_path}" ]] || fail_test "2FA reset database backup missing" grep -Fq "email='admin@example.com' AND role='admin'" "${compose_calls}" || fail_test "2FA administrator lookup is not email and role restricted" grep -Fq "UPDATE users SET two_factor_secret=''" "${compose_calls}" || fail_test "administrator 2FA flag was not cleared" grep -Fq "DELETE FROM two_factor_recovery_codes WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator recovery codes were not deleted" grep -Fq "DELETE FROM login_challenges WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "administrator login challenges were not deleted" ) test_offline_database_backup() ( local temp_dir destination temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" mkdir -p "${INSTALL_DIR}/data/backups" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");' compose() { return 0; } destination="${INSTALL_DIR}/data/backups/offline.db" backup_database "${destination}" "unused-image" [[ -s "${destination}" ]] || fail_test "offline database backup missing" assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content" ) test_guide_generation() ( local temp_dir temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" CERT_DIR="${INSTALL_DIR}/certs" GUIDE_FILE="${temp_dir}/guide.txt" mkdir -p "${CERT_DIR}" cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env" set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com" set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com" set_env LANQIN_MAIL_DOMAIN "example.com" set_env LANQIN_ADMIN_EMAIL "admin@example.com" generate_guide grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing" grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing" grep -Fq '管理员邮箱:admin@example.com' "${GUIDE_FILE}" || fail_test "guide administrator email missing" grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing" [[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600" ) test_acme_cron_detection() ( crontab() { printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n' } acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected" ) test_cli_alias_safety() ( local temp_dir temp_dir="$(mktemp -d)" CLI_PATH="${temp_dir}/newszxcn-email" CLI_ALIAS_PATH="${temp_dir}/ns" printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}" chmod 0755 "${CLI_PATH}" ensure_cli_alias [[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created" assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target" rm -f "${CLI_ALIAS_PATH}" printf 'occupied\n' > "${CLI_ALIAS_PATH}" ensure_cli_alias grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten" ) test_restore_source_validation() ( local temp_dir temp_dir="$(mktemp -d)" mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs" printf 'config\n' > "${temp_dir}/.env" printf 'services: {}\n' > "${temp_dir}/docker-compose.yml" sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected" validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected" printf 'damaged\n' > "${temp_dir}/data/lanqin.db" if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then fail_test "damaged restore database accepted" fi rm -f "${temp_dir}/data/lanqin.db" if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then fail_test "restore source without database accepted" fi ) test_restore_menu_rendering_and_dispatch() ( local output action_file LANQIN_MENU_ACTION=1 action_file="$(mktemp)" prompt_text() { printf '%b' "$1"; } output="$(render_restore_menu)" [[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing" [[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing" [[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing" [[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing" prompt_text() { :; } do_restore_backup() { printf 'restore\n' > "${action_file}"; } do_restore_menu grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload" LANQIN_MENU_ACTION=2 do_restore_menu >/dev/null unset LANQIN_MENU_ACTION ) test_restore_backup_discovery() ( local temp_dir output selected temp_dir="$(mktemp -d)" LANQIN_RESTORE_SEARCH_DIR="${temp_dir}" touch "${temp_dir}/unrelated.tar.zst.enc" output="$(discover_restore_backups)" [[ -z "${output}" ]] || fail_test "unrelated archive was discovered" touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" selected="$(select_restore_source)" assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup" touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" output="$(discover_restore_backups)" assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering" LANQIN_RESTORE_SELECTION=2 selected="$(select_restore_source)" assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup" ) test_encrypted_restore_archive() ( local temp_dir source_dir archive extracted password='RestorePassword123!' temp_dir="$(mktemp -d)" source_dir="${temp_dir}/source/newszxcn-email" archive="${temp_dir}/newszxcn-backup.tar.zst.enc" extracted="${temp_dir}/extracted" mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}" printf 'config\n' > "${source_dir}/.env" printf 'services: {}\n' > "${source_dir}/docker-compose.yml" sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' zstd() { if [[ "$*" == '-q -c' ]]; then gzip -c elif [[ "$1" == '-dc' ]]; then gzip -dc "$2" else return 1 fi } tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \ openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}" LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}" validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed" ) test_failed_full_restore_cleans_partial_install() ( local temp_dir source_dir archive password='RestorePassword123!' temp_dir="$(mktemp -d)" source_dir="${temp_dir}/source/newszxcn-backup" archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc" INSTALL_DIR="${temp_dir}/install" NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf" CERT_DIR="${temp_dir}/certs" LANQIN_RESTORE_SOURCE="${archive}" LANQIN_RESTORE_PASSWORD="${password}" mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")" printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env" printf 'services: {}\n' > "${source_dir}/docker-compose.yml" sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' zstd() { if [[ "$*" == '-q -c' ]]; then gzip -c elif [[ "$1" == '-dc' ]]; then gzip -dc "$2" else return 1 fi } tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \ openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}" refresh_assets() { return 0; } ensure_update_token() { return 0; } ensure_admin_email_config() { return 0; } configure_runtime_bindings() { return 0; } ensure_docker() { return 0; } configure_firewall() { return 0; } prepare_directories() { return 0; } compose() { case "$1" in pull) return 1 ;; down) return 0 ;; esac return 0 } if (do_restore_backup >/dev/null 2>&1); then fail_test "failed full restore unexpectedly succeeded" fi [[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation" [[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup" ) test_restore_archive_path_validation() ( printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected" printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted" printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted" printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected" printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected" printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted" ) test_compose_runtime_image_pin() ( local temp_dir calls temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image" calls="${temp_dir}/docker-calls" mkdir -p "${INSTALL_DIR}" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}" docker() { printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}" } compose ps grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose" clear_runtime_image_pin compose ps [[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose" ) test_update_snapshot_restore() ( local temp_dir snapshot temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" CERT_DIR="${INSTALL_DIR}/certs" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" CLI_PATH="${temp_dir}/newszxcn-email-cli" CLI_ALIAS_PATH="${temp_dir}/ns" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest" RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image" mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}" printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env" printf 'old-example\n' > "${INSTALL_DIR}/.env.example" printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}" chmod 0755 "${CLI_PATH}" printf 'old-nginx\n' > "${NGINX_CONFIG}" printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");' current_image_id() { printf 'sha256:old-image\n'; } docker() { if [[ "$*" == *'org.opencontainers.image.version'* ]]; then printf '1.2.4\n' fi return 0 } compose() { if [[ "${1:-}" == "up" ]]; then grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin" fi return 0 } nginx() { return 0; } systemctl() { return 0; } wait_for_health() { return 0; } ensure_cli_alias() { return 0; } create_update_snapshot snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")" [[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing" printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env" printf 'new-example\n' > "${INSTALL_DIR}/.env.example" printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}" printf 'new-nginx\n' > "${NGINX_CONFIG}" printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");' restore_update_snapshot "${snapshot}" grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored" grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored" grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored" grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored" grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored" grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored" assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content" assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin" ) test_snapshot_restores_absent_optional_files() ( local temp_dir snapshot temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" CERT_DIR="${INSTALL_DIR}/certs" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" CLI_PATH="${temp_dir}/newszxcn-email-cli" CLI_ALIAS_PATH="${temp_dir}/ns" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest" RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image" mkdir -p "${INSTALL_DIR}/data/backups" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");' current_image_id() { printf 'sha256:old-image\n'; } docker() { return 0; } compose() { return 0; } nginx() { return 0; } systemctl() { return 0; } wait_for_health() { return 0; } ensure_cli_alias() { return 0; } create_update_snapshot snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")" [[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker" [[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker" [[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker" [[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker" mkdir -p "${CERT_DIR}" printf 'new-example\n' > "${INSTALL_DIR}/.env.example" printf '#!/bin/sh\n' > "${CLI_PATH}" printf 'new-nginx\n' > "${NGINX_CONFIG}" printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem" restore_update_snapshot "${snapshot}" [[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback" [[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback" [[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback" [[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback" ) test_pre_start_restore_preserves_current_database() ( local temp_dir snapshot temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/install" CERT_DIR="${INSTALL_DIR}/certs" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" CLI_PATH="${temp_dir}/newszxcn-email-cli" CLI_ALIAS_PATH="${temp_dir}/ns" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest" RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image" mkdir -p "${INSTALL_DIR}/data/backups" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");' current_image_id() { printf 'sha256:old-image\n'; } docker() { return 0; } compose() { return 0; } reload_nginx() { return 0; } wait_for_health() { return 0; } ensure_cli_alias() { return 0; } create_update_snapshot snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")" sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");' restore_update_snapshot "${snapshot}" false assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start" assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull" ) test_failed_asset_validation_preserves_production() ( local temp_dir source_dir temp_dir="$(mktemp -d)" source_dir="${temp_dir}/source" INSTALL_DIR="${temp_dir}/install" CLI_PATH="${temp_dir}/newszxcn-email-cli" RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image" mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}" printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env" printf 'old-example\n' > "${INSTALL_DIR}/.env.example" printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}" printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}" printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml" cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example" cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh" script_dir() { printf '%s\n' "${source_dir}"; } docker() { return 1; } if (stage_assets >/dev/null 2>&1); then fail_test "invalid Compose file passed staging validation" fi grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation" grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation" grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation" grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation" ) test_backup_reinstall_restores_on_failure() ( local temp_dir failed_dir temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/newszxcn-email" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" CLI_PATH="${temp_dir}/newszxcn-email-cli" CLI_ALIAS_PATH="${temp_dir}/ns" mkdir -p "${INSTALL_DIR}" printf 'existing-data\n' > "${INSTALL_DIR}/marker" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'old-nginx\n' > "${NGINX_CONFIG}" printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}" chmod 0755 "${CLI_PATH}" ensure_docker() { return 0; } current_image_id() { printf 'sha256:old-image\n'; } compose() { return 0; } nginx() { return 0; } systemctl() { return 0; } wait_for_health() { return 0; } ensure_cli_alias() { return 0; } do_install() { mkdir -p "${INSTALL_DIR}" printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker" return 1 } if (do_backup_reinstall); then fail_test "failed reinstall unexpectedly succeeded" fi grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored" grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored" failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)" [[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved" ) test_backup_reinstall_recovers_from_nginx_reload_failure() ( local temp_dir compose_calls reload_count_file temp_dir="$(mktemp -d)" INSTALL_DIR="${temp_dir}/newszxcn-email" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" CLI_PATH="${temp_dir}/newszxcn-email-cli" CLI_ALIAS_PATH="${temp_dir}/ns" compose_calls="${temp_dir}/compose-calls" reload_count_file="${temp_dir}/reload-count" mkdir -p "${INSTALL_DIR}" printf 'existing-data\n' > "${INSTALL_DIR}/marker" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'old-nginx\n' > "${NGINX_CONFIG}" printf '0\n' > "${reload_count_file}" ensure_docker() { return 0; } current_image_id() { printf 'sha256:old-image\n'; } compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; } reload_nginx() { local count count="$(cat "${reload_count_file}")" printf '%s\n' "$((count + 1))" > "${reload_count_file}" [[ "${count}" -gt 0 ]] } wait_for_health() { return 0; } do_install() { fail_test "fresh install started after Nginx reload failure"; } if (do_backup_reinstall >/dev/null 2>&1); then fail_test "reinstall continued after Nginx reload failure" fi grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure" grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure" grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure" ) test_hostname_validation test_password_validation test_mail_domain_and_admin_email_validation test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false" test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false" test_nginx_configuration test_compose_configuration test_legacy_configuration_is_preserved test_menu_choice test_menu_rendering test_menu_dispatch test_menu_runtime_metadata test_incomplete_install_defaults_to_repair test_service_commands_require_complete_installation test_admin_credentials test_admin_password_hash_parsing test_admin_password_reset_only_updates_admin_account test_admin_two_factor_reset_only_updates_admin_account test_offline_database_backup test_guide_generation test_acme_cron_detection test_cli_alias_safety test_restore_source_validation test_restore_menu_rendering_and_dispatch test_restore_backup_discovery test_encrypted_restore_archive test_failed_full_restore_cleans_partial_install test_restore_archive_path_validation test_compose_runtime_image_pin test_update_snapshot_restore test_snapshot_restores_absent_optional_files test_pre_start_restore_preserves_current_database test_failed_asset_validation_preserves_production test_backup_reinstall_restores_on_failure test_backup_reinstall_recovers_from_nginx_reload_failure printf 'install.sh tests passed\n'