2026-08-03 22:39:51 +08:00
#!/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
2026-08-04 03:40:34 +08:00
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
2026-08-03 22:39:51 +08:00
}
2026-08-05 02:55:36 +08:00
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"
2026-08-07 13:29:03 +08:00
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"
2026-08-05 02:55:36 +08:00
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
}
2026-08-03 22:39:51 +08:00
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"
2026-08-05 02:55:36 +08:00
export LANQIN_MAIL_DOMAIN = "example.com"
export LANQIN_ADMIN_EMAIL = "admin@example.com"
2026-08-03 22:39:51 +08:00
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"
2026-08-05 02:55:36 +08:00
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"
2026-08-03 22:39:51 +08:00
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"
}
2026-08-03 23:29:02 +08:00
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"
2026-08-04 03:40:34 +08:00
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
2026-08-04 02:23:46 +08:00
fail_test "out-of-range menu action accepted"
fi
2026-08-03 23:29:02 +08:00
unset LANQIN_MENU_ACTION
2026-08-03 23:09:59 +08:00
}
2026-08-07 13:29:03 +08:00
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"
2026-08-12 16:15:19 +08:00
[[ " ${ output } " == *'2. 备份恢复' * ]] || fail_test "uninstalled menu restore action missing"
[[ " ${ output } " == *'3. 退出' * ]] || fail_test "uninstalled menu exit action missing"
2026-08-07 13:29:03 +08:00
[[ " ${ 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 } " ; }
2026-08-12 16:15:19 +08:00
do_restore_menu() { printf 'restore-menu\n' > " ${ action_file } " ; }
2026-08-07 13:29:03 +08:00
do_menu
grep -Fq 'install' " ${ action_file } " || fail_test "uninstalled menu did not dispatch install"
2026-08-12 16:15:19 +08:00
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
2026-08-07 13:29:03 +08:00
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 = "<no value>"
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 } "
2026-08-07 13:32:32 +08:00
# Invoked indirectly by the service command functions under test.
# shellcheck disable=SC2317,SC2329
2026-08-07 13:29:03 +08:00
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
)
2026-08-04 03:40:34 +08:00
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
2026-08-05 02:55:36 +08:00
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
2026-08-04 03:40:34 +08:00
LANQIN_ADMIN_PASSWORD=recorded-password
EOF
output = " $( do_show_admin_credentials 2>& 1) "
[[ " ${ output } " == *'登录地址:https://mail.example.com' * ]] || fail_test "administrator login URL missing"
2026-08-05 02:55:36 +08:00
[[ " ${ output } " == *'管理员邮箱:admin@example.com' * ]] || fail_test "administrator email missing"
2026-08-04 03:40:34 +08:00
[[ " ${ 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'
2026-08-05 02:55:36 +08:00
LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_MAIL_DOMAIN=example.com
LANQIN_ADMIN_EMAIL=admin@example.com
2026-08-04 03:40:34 +08:00
LANQIN_ADMIN_PASSWORD=old-password
EOF
2026-08-07 13:29:03 +08:00
printf 'services: {}\n' > " ${ INSTALL_DIR } /docker-compose.yml"
2026-08-04 03:40:34 +08:00
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"
2026-08-05 02:55:36 +08:00
grep -Fq "email='admin@example.com' AND role='admin'" " ${ compose_calls } " || fail_test "administrator lookup is not email and role restricted"
2026-08-04 03:40:34 +08:00
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"
)
2026-08-05 02:55:36 +08:00
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
2026-08-07 13:29:03 +08:00
printf 'services: {}\n' > " ${ INSTALL_DIR } /docker-compose.yml"
2026-08-05 02:55:36 +08:00
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"
)
2026-08-04 02:23:46 +08:00
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"
2026-08-05 02:55:36 +08:00
set_env LANQIN_MAIL_DOMAIN "example.com"
set_env LANQIN_ADMIN_EMAIL "admin@example.com"
2026-08-04 02:23:46 +08:00
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"
2026-08-05 02:55:36 +08:00
grep -Fq '管理员邮箱:admin@example.com' " ${ GUIDE_FILE } " || fail_test "guide administrator email missing"
2026-08-04 02:23:46 +08:00
grep -Fq '管理员密码:仅在安装完成时显示' " ${ GUIDE_FILE } " || fail_test "guide password safety text missing"
2026-08-04 02:28:21 +08:00
[[ " $( stat -c '%a' " ${ GUIDE_FILE } " 2>/dev/null || stat -f '%Lp' " ${ GUIDE_FILE } " ) " == "600" ]] || fail_test "guide permissions are not 600"
2026-08-04 02:23:46 +08:00
)
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"
)
2026-08-12 16:15:19 +08:00
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"
)
2026-08-04 02:23:46 +08:00
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
2026-08-03 23:23:41 +08:00
temp_dir = " $( mktemp -d) "
INSTALL_DIR = " ${ temp_dir } /newszxcn-email"
NGINX_CONFIG = " ${ temp_dir } /newszxcn-email.conf"
2026-08-04 02:23:46 +08:00
CLI_PATH = " ${ temp_dir } /newszxcn-email-cli"
CLI_ALIAS_PATH = " ${ temp_dir } /ns"
2026-08-03 23:23:41 +08:00
mkdir -p " ${ INSTALL_DIR } "
printf 'existing-data\n' > " ${ INSTALL_DIR } /marker"
2026-08-04 02:23:46 +08:00
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 } "
2026-08-03 23:23:41 +08:00
2026-08-04 02:23:46 +08:00
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; }
2026-08-03 23:23:41 +08:00
do_install() {
2026-08-04 02:23:46 +08:00
mkdir -p " ${ INSTALL_DIR } "
printf 'failed-install\n' > " ${ INSTALL_DIR } /failed-marker"
return 1
2026-08-03 23:23:41 +08:00
}
2026-08-04 02:23:46 +08:00
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"
2026-08-03 23:23:41 +08:00
)
2026-08-03 22:39:51 +08:00
test_hostname_validation
test_password_validation
2026-08-05 02:55:36 +08:00
test_mail_domain_and_admin_email_validation
2026-08-03 22:39:51 +08:00
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
2026-08-03 23:29:02 +08:00
test_menu_choice
2026-08-07 13:29:03 +08:00
test_menu_rendering
test_menu_dispatch
test_menu_runtime_metadata
test_incomplete_install_defaults_to_repair
test_service_commands_require_complete_installation
2026-08-04 03:40:34 +08:00
test_admin_credentials
test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account
2026-08-05 02:55:36 +08:00
test_admin_two_factor_reset_only_updates_admin_account
2026-08-04 02:23:46 +08:00
test_offline_database_backup
test_guide_generation
test_acme_cron_detection
test_cli_alias_safety
2026-08-12 16:15:19 +08:00
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
2026-08-04 02:23:46 +08:00
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
2026-08-03 22:39:51 +08:00
printf 'install.sh tests passed\n'