Files
dashcaddy/scripts/test-start-sh-migration.sh
T
Hermes Agent 894e091335
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-050 harden dataDir + add image-layer migration
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.
2026-07-20 00:53:05 -07:00

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