import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, cpSync, writeFileSync, readFileSync, statSync, rmSync, realpathSync, } from 'node:fs'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; const root = resolve('.'); const folder = mkdtempSync(resolve(tmpdir(), 'hop-nas-config-')); const realDocker = execFileSync('which', ['docker'], { encoding: 'utf8' }).trim(); let checkedContainer, checkedVolume; try { mkdirSync(resolve(folder, 'scripts')); mkdirSync(resolve(folder, 'bin')); for (const name of ['configure-nas.sh', 'prepare-nas-storage.sh']) cpSync(resolve(root, 'scripts', name), resolve(folder, 'scripts', name)); const composeFile = resolve(folder, 'docker-compose.yaml'); cpSync(resolve(root, 'docker-compose.yaml'), composeFile); const originalCompose = readFileSync(composeFile, 'utf8'); const envFile = resolve(folder, '.env'); writeFileSync( envFile, [ 'HOP_BIND_IP=192.0.2.1', 'HOP_HOST_PORT=50189', 'HOP_CONTAINER_PORT=50189', 'APP_URL=https://hop.test', 'SESSION_SECRET=keep-test-session-value-1234567890', 'DISCOVERY_SECRET=keep-test-discovery-value-1234567890', 'PROXY_DIAGNOSTIC_TOKEN=keep-test-diagnostic-value-1234567890', 'USER_STORAGE_LIMIT_MB=2048', '', ].join('\n'), ); const log = resolve(folder, 'docker-operations.log'); writeFileSync( resolve(folder, 'bin', 'docker'), `#!/bin/sh set -eu case "$1" in compose) exec "$HOP_TEST_REAL_DOCKER" "$@" ;; inspect) if [ "$HOP_TEST_RUNNING" = 1 ]; then if [ "$2" = --format ]; then printf '%s\\n' true; else printf '%s\\n' '{}'; fi exit 0 fi exit 1 ;; volume|run) printf '%s\\n' "$*" >> "$HOP_TEST_DOCKER_LOG" ;; *) exit 1 ;; esac `, { mode: 0o755 }, ); const environment = { ...process.env, PATH: `${folder}/bin:${process.env.PATH}`, SUDO_UID: '12001', SUDO_GID: '12002', HOP_TEST_REAL_DOCKER: realDocker, HOP_TEST_DOCKER_LOG: log, HOP_TEST_RUNNING: '0', }; execFileSync('sh', ['scripts/configure-nas.sh'], { cwd: folder, env: environment }); const updated = readFileSync(envFile, 'utf8'); assert(updated.includes('HOP_RUN_UID=12001\n')); assert(updated.includes('HOP_RUN_GID=12002\n')); assert(updated.includes('SESSION_SECRET=keep-test-session-value-1234567890')); assert(updated.includes('DISCOVERY_SECRET=keep-test-discovery-value-1234567890')); assert.equal(statSync(envFile).mode & 0o777, 0o600); const yaml = readFileSync(composeFile, 'utf8'); assert.equal(yaml, originalCompose, 'NAS setup must not overwrite the tracked Compose file'); assert(!yaml.includes('keep-test-session-value')); const config = JSON.parse( execFileSync( realDocker, ['compose', '-f', 'docker-compose.yaml', 'config', '--no-env-resolution', '--format', 'json'], { cwd: folder, encoding: 'utf8' }, ), ); assert.equal(config.services.hop.user, '12001:12002'); assert.equal(config.name, 'hop'); assert.equal(config.services.hop.container_name, 'hop'); assert.equal(config.networks.hop_network.name, 'hop_hop_network'); const files = config.services.hop.volumes.find((mount) => mount.target === '/data/uploads'); assert.equal(files.type, 'bind'); assert.equal(files.source, realpathSync(resolve(folder, 'uploads'))); assert.equal(files.bind.create_host_path, false); const database = config.services.hop.volumes.find((mount) => mount.target === '/data/database'); assert.equal(database.volume.nocopy, true); assert.equal(config.volumes.hop_database.name, 'hop_hop_database'); assert.equal(config.volumes.hop_uploads, undefined); assert(!readFileSync(log, 'utf8').includes('volume rm')); assert(readFileSync(log, 'utf8').includes('com.docker.compose.volume=hop_database')); const before = readFileSync(log, 'utf8'); assert.throws( () => execFileSync('sh', ['scripts/prepare-nas-storage.sh'], { cwd: folder, env: { ...environment, HOP_TEST_RUNNING: '1' }, stdio: 'pipe', }), /Command failed/, ); assert.equal(readFileSync(log, 'utf8'), before, 'running deployment refuses permission changes'); assert(readFileSync(resolve(root, '.gitignore'), 'utf8').includes('/uploads/')); assert(readFileSync(resolve(root, '.dockerignore'), 'utf8').split('\n').includes('uploads')); if (process.env.HOP_CHECK_IMAGE) { const suffix = randomUUID().slice(0, 8), project = `hop-compose-check-${suffix}`; checkedContainer = project; checkedVolume = `${project}-db`; const run = (...args) => execFileSync(realDocker, args, { encoding: 'utf8', stdio: 'pipe' }); run( 'volume', 'create', '--label', `com.docker.compose.project=${project}`, '--label', 'com.docker.compose.volume=hop_database', checkedVolume, ); run( 'run', '--rm', '--network', 'none', '--user', '0:0', '--mount', `type=volume,source=${checkedVolume},target=/database`, '--mount', `type=bind,source=${realpathSync(resolve(folder, 'uploads'))},target=/uploads`, process.env.HOP_CHECK_IMAGE, 'sh', '-eu', '-c', 'chown 12001:12002 /database /uploads; chmod 700 /database /uploads', ); config.name = project; config.volumes.hop_database.name = checkedVolume; Object.assign(config.services.hop, { image: process.env.HOP_CHECK_IMAGE, // This fixture checks mounts with a prebuilt image, without source builds. pull_policy: 'never', container_name: checkedContainer, ports: [], network_mode: 'none', }); delete config.services.hop.build; delete config.services.hop.networks; delete config.networks; const checkYaml = resolve(folder, 'compose.check.json'); writeFileSync(checkYaml, JSON.stringify(config)); run('compose', '-f', checkYaml, 'up', '-d', '--no-build'); run( 'exec', checkedContainer, 'node', '-e', "(async()=>{for(let i=0;i<60;i++){try{if((await fetch('http://127.0.0.1:50189/api/health')).ok)return}catch{}await new Promise(r=>setTimeout(r,250));}process.exit(1)})()", ); const mounts = JSON.parse(run('inspect', '--format', '{{json .Mounts}}', checkedContainer)); const bind = mounts.find((mount) => mount.Destination === '/data/uploads'); assert.equal(bind.Type, 'bind'); const hostSource = process.platform === 'darwin' ? bind.Source.replace(/^\/host_mnt/, '') : bind.Source; assert.equal(realpathSync(hostSource), realpathSync(resolve(folder, 'uploads'))); assert.equal( run('inspect', '--format', '{{.Config.User}}', checkedContainer).trim(), '12001:12002', ); assert.equal( run('inspect', '--format', '{{json .HostConfig.PortBindings}}', checkedContainer).trim(), '{}', ); } console.log( 'PASS: single tracked Compose unchanged by setup, existing project/container/network/database identities, NAS UID/GID, secret preservation, private env file, resolved uploads bind path, no DB copy-up, stopped-container guard, no implicit data deletion, uploads excluded from Git/build. Compose parsed with real Docker; storage mutations isolated through a test shim.', ); } finally { if (checkedContainer) { try { execFileSync(realDocker, ['rm', '-f', checkedContainer], { stdio: 'pipe' }); } catch {} } if (checkedVolume) { try { execFileSync(realDocker, ['volume', 'rm', checkedVolume], { stdio: 'pipe' }); } catch {} } rmSync(folder, { recursive: true, force: true }); }