Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.
1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
copies non-empty content to bind mount with 'migrated-' prefix,
gated by sentinel file. Survives set -e per-file failures.
19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).
Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
193 lines
7.8 KiB
Bash
Executable File
193 lines
7.8 KiB
Bash
Executable File
#!/bin/bash
|
|
# DC-039 follow-up — regression test for start.sh image-layer migration step.
|
|
# Validates: idempotency, partial files, missing files, sentinel creation,
|
|
# set -e doesn't kill the script on a single per-file failure.
|
|
|
|
set -u
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
FAILURES=0
|
|
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1"; FAILURES=$((FAILURES + 1)); }
|
|
|
|
# ---- Setup helpers ----------------------------------------------------------
|
|
# Source only the migration function out of start.sh — don't run the whole
|
|
# script (it would try to bind to port 3001 + manage docker). Use the same
|
|
# sh-extraction pattern as test-dashcaddy-update-backup.sh.
|
|
|
|
fresh_data_dir() {
|
|
local d
|
|
d="$(mktemp -d /tmp/dashcaddy-migration-test.XXXXXX)"
|
|
echo "${d}"
|
|
}
|
|
|
|
clean_data_dir() {
|
|
rm -rf "$1" 2>/dev/null || true
|
|
}
|
|
|
|
# Extract just the migration logic — it's the only block we want to test.
|
|
extract_migration() {
|
|
sed -n '/^MIGRATION_SENTINEL=/,/^run_image_layer_migration$/p' "${SCRIPT_DIR}/../start.sh"
|
|
}
|
|
|
|
# Test 1: Sentinel file present → migration skips entirely
|
|
echo "Test 1: sentinel exists → no copies"
|
|
DATA_DIR="$(fresh_data_dir)"
|
|
touch "${DATA_DIR}/.migrated-from-image-layer"
|
|
extract_migration > /tmp/_migration_extract.sh
|
|
# Override DATA_DIR to point at our test dir
|
|
# Strip the actual call (the trailing 'run_image_layer_migration') so we
|
|
# control invocation; in tests we re-define DATA_DIR first.
|
|
{
|
|
echo "DATA_DIR='${DATA_DIR}'"
|
|
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
|
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
|
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
|
} > /tmp/_migration_block.sh
|
|
# shellcheck disable=SC1091
|
|
source /tmp/_migration_block.sh
|
|
# Plant a fake zombie that should NOT be migrated because the sentinel exists
|
|
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
|
mkdir -p "${ZOMBIE_DIR}/security"
|
|
echo '{"data":"should not be migrated"}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
|
run_image_layer_migration
|
|
if [ -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
|
fail "test 1: sentinel existed, migration should have skipped but a file appeared"
|
|
else
|
|
pass "sentinel skipped migration cleanly"
|
|
fi
|
|
rm -rf "${ZOMBIE_DIR}"
|
|
clean_data_dir "${DATA_DIR}"
|
|
|
|
# Test 2: No sentinel + non-empty zombie → migration copies file
|
|
echo "Test 2: zombie file present → migration copies to bind mount"
|
|
DATA_DIR="$(fresh_data_dir)"
|
|
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
|
mkdir -p "${ZOMBIE_DIR}/security" "${ZOMBIE_DIR}/managers"
|
|
echo '{"audit":"prod data"}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
|
echo "license-secret-blob" > "${ZOMBIE_DIR}/managers/.license-secret"
|
|
{
|
|
echo "DATA_DIR='${DATA_DIR}'"
|
|
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
|
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
|
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
|
} > /tmp/_migration_block2.sh
|
|
# Stub out the real zombie paths to point at our temp zombie
|
|
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block2.sh
|
|
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/managers/.license-secret|${ZOMBIE_DIR}/managers/.license-secret|g" /tmp/_migration_block2.sh
|
|
# shellcheck disable=SC1091
|
|
source /tmp/_migration_block2.sh
|
|
run_image_layer_migration
|
|
if [ ! -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
|
fail "test 2: audit-log.json not migrated"
|
|
elif ! grep -q "audit.*prod data" "${DATA_DIR}/migrated-audit-log.json"; then
|
|
fail "test 2: audit-log.json migrated but content corrupt"
|
|
else
|
|
pass "audit-log.json migrated with correct content"
|
|
fi
|
|
if [ ! -f "${DATA_DIR}/migrated-.license-secret" ]; then
|
|
fail "test 2: .license-secret not migrated"
|
|
else
|
|
pass ".license-secret migrated"
|
|
fi
|
|
if [ ! -f "${DATA_DIR}/.migrated-from-image-layer" ]; then
|
|
fail "test 2: sentinel file was not written"
|
|
else
|
|
pass "sentinel file written"
|
|
fi
|
|
rm -rf "${ZOMBIE_DIR}"
|
|
clean_data_dir "${DATA_DIR}"
|
|
|
|
# Test 3: Idempotency — running migration twice does NOT clobber first copy
|
|
echo "Test 3: idempotency"
|
|
DATA_DIR="$(fresh_data_dir)"
|
|
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
|
mkdir -p "${ZOMBIE_DIR}/security"
|
|
echo '{"first":true}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
|
{
|
|
echo "DATA_DIR='${DATA_DIR}'"
|
|
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
|
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
|
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
|
} > /tmp/_migration_block3.sh
|
|
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block3.sh
|
|
# shellcheck disable=SC1091
|
|
source /tmp/_migration_block3.sh
|
|
run_image_layer_migration
|
|
echo '{"second":true}' > "${ZOMBIE_DIR}/security/audit-log.json" # mutate the source after migration
|
|
run_image_layer_migration
|
|
if grep -q "first.*true" "${DATA_DIR}/migrated-audit-log.json"; then
|
|
pass "second run did not overwrite first migrated content"
|
|
else
|
|
fail "second run overwrote the migrated file"
|
|
fi
|
|
rm -rf "${ZOMBIE_DIR}"
|
|
clean_data_dir "${DATA_DIR}"
|
|
|
|
# Test 4: Zero-byte zombie (empty file) → NOT migrated
|
|
echo "Test 4: empty file is not migrated"
|
|
DATA_DIR="$(fresh_data_dir)"
|
|
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
|
mkdir -p "${ZOMBIE_DIR}/security"
|
|
touch "${ZOMBIE_DIR}/security/audit-log.json" # zero bytes
|
|
{
|
|
echo "DATA_DIR='${DATA_DIR}'"
|
|
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
|
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
|
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
|
} > /tmp/_migration_block4.sh
|
|
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block4.sh
|
|
# shellcheck disable=SC1091
|
|
source /tmp/_migration_block4.sh
|
|
run_image_layer_migration
|
|
if [ -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
|
fail "test 4: empty file should not be migrated"
|
|
else
|
|
pass "empty file correctly skipped"
|
|
fi
|
|
if [ -f "${DATA_DIR}/.migrated-from-image-layer" ]; then
|
|
pass "sentinel still written even with zero zombies"
|
|
else
|
|
fail "sentinel should still be written even with no zombies"
|
|
fi
|
|
rm -rf "${ZOMBIE_DIR}"
|
|
clean_data_dir "${DATA_DIR}"
|
|
|
|
# Test 5: set -e present + all per-file failures → script doesn't take down container
|
|
echo "Test 5: a single per-file failure does not bring down the container"
|
|
DATA_DIR="$(fresh_data_dir)"
|
|
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
|
mkdir -p "${ZOMBIE_DIR}/security"
|
|
echo "x" > "${ZOMBIE_DIR}/security/audit-log.json"
|
|
chmod 000 "${ZOMBIE_DIR}/security/audit-log.json" # make it unreadable so cp -a fails
|
|
{
|
|
set -e # NOW we need to verify the inner guard prevents set -e from killing us
|
|
echo "DATA_DIR='${DATA_DIR}'"
|
|
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
|
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
|
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
|
} > /tmp/_migration_block5.sh
|
|
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block5.sh
|
|
EXIT=0
|
|
# shellcheck disable=SC1091
|
|
source /tmp/_migration_block5.sh && run_image_layer_migration || EXIT=$?
|
|
chmod 644 "${ZOMBIE_DIR}/security/audit-log.json" 2>/dev/null || true
|
|
rm -rf "${ZOMBIE_DIR}"
|
|
clean_data_dir "${DATA_DIR}"
|
|
if [ "$EXIT" -eq 0 ]; then
|
|
pass "script survived a per-file cp failure"
|
|
else
|
|
fail "set -e propagated a per-file failure (exit ${EXIT}); container would not boot"
|
|
fi
|
|
|
|
# ---- Cleanup ----------------------------------------------------------------
|
|
rm -f /tmp/_migration_extract.sh /tmp/_migration_block*.sh
|
|
|
|
echo
|
|
if [ "$FAILURES" -eq 0 ]; then
|
|
echo "All migration regression tests passed."
|
|
exit 0
|
|
fi
|
|
echo "${FAILURES} test(s) failed."
|
|
exit 1
|