Use visible NAS upload storage and reconcile missing files
This commit is contained in:
193
tests/docker/nas-config.mjs
Normal file
193
tests/docker/nas-config.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
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));
|
||||
cpSync(resolve(root, 'compose.yaml'), resolve(folder, 'compose.yaml'));
|
||||
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(resolve(folder, 'compose.nas.yaml'), 'utf8');
|
||||
assert(!yaml.includes('keep-test-session-value'));
|
||||
const config = JSON.parse(
|
||||
execFileSync(
|
||||
realDocker,
|
||||
['compose', '-f', 'compose.nas.yaml', 'config', '--no-env-resolution', '--format', 'json'],
|
||||
{ cwd: folder, encoding: 'utf8' },
|
||||
),
|
||||
);
|
||||
assert.equal(config.services.hop.user, '12001:12002');
|
||||
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,
|
||||
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: NAS UID/GID, secret preservation, private env file, absolute 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 });
|
||||
}
|
||||
@@ -1,17 +1,41 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
const suffix = randomUUID().slice(0, 8),
|
||||
name = `hop-check-${suffix}`,
|
||||
volume = `hop-check-db-${suffix}`,
|
||||
uploads = `hop-check-files-${suffix}`;
|
||||
const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published.
|
||||
const bindUploads = process.env.HOP_CHECK_BIND_UPLOADS === '1';
|
||||
const folder = bindUploads ? mkdtempSync(resolve(tmpdir(), 'hop-bind-files-')) : null;
|
||||
const runtimeUser = bindUploads ? '12001:12001' : '1000:1000';
|
||||
const docker = (...args) =>
|
||||
execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code);
|
||||
try {
|
||||
docker('volume', 'create', volume);
|
||||
docker('volume', 'create', uploads);
|
||||
if (bindUploads) {
|
||||
docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
'none',
|
||||
'--user',
|
||||
'0:0',
|
||||
'-v',
|
||||
`${volume}:/database`,
|
||||
'--mount',
|
||||
`type=bind,source=${folder},target=/uploads`,
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
'sh',
|
||||
'-eu',
|
||||
'-c',
|
||||
`chown 12001:12001 /database /uploads; chmod 700 /database /uploads`,
|
||||
);
|
||||
} else docker('volume', 'create', uploads);
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
@@ -19,6 +43,8 @@ try {
|
||||
name,
|
||||
'--network',
|
||||
'none',
|
||||
'--user',
|
||||
runtimeUser,
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
@@ -31,10 +57,12 @@ try {
|
||||
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
'-e',
|
||||
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
'-v',
|
||||
`${volume}:/data/database`,
|
||||
'-v',
|
||||
`${uploads}:/data/uploads`,
|
||||
...(bindUploads
|
||||
? ['--mount', `type=volume,source=${volume},target=/data/database,volume-nocopy`]
|
||||
: ['-v', `${volume}:/data/database`]),
|
||||
...(bindUploads
|
||||
? ['--mount', `type=bind,source=${folder},target=/data/uploads`]
|
||||
: ['-v', `${uploads}:/data/uploads`]),
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
);
|
||||
const wait = () =>
|
||||
@@ -80,9 +108,41 @@ try {
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
|
||||
);
|
||||
assert.equal(integrity, 'ok');
|
||||
if (bindUploads) {
|
||||
assert.equal(docker('inspect', '--format', '{{.Config.User}}', name), runtimeUser);
|
||||
const mode = JSON.parse(
|
||||
node(
|
||||
`import {statSync} from 'node:fs';const s=statSync('/data/uploads/originals');console.log(JSON.stringify({uid:s.uid,gid:s.gid,mode:s.mode&511}));`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
||||
const originals = resolve(folder, 'originals');
|
||||
const stored = readdirSync(originals).find(
|
||||
(key) => readFileSync(resolve(originals, key)).toString() === 'Docker file persistence',
|
||||
);
|
||||
assert(stored, 'uploaded file is visible in the host project directory');
|
||||
unlinkSync(resolve(originals, stored));
|
||||
const missing = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`,
|
||||
),
|
||||
);
|
||||
assert.equal(missing.status, 404);
|
||||
assert.equal(missing.body.code, 'FILE_NOT_FOUND');
|
||||
const visible = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify((await r.json()).items));`,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
visible.some((item) => item.accessId === fileState.accessId),
|
||||
false,
|
||||
);
|
||||
assert(visible.some((item) => item.file?.name === 'resume.bin'));
|
||||
}
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
console.log(
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
@@ -92,8 +152,9 @@ try {
|
||||
try {
|
||||
docker('rm', '-f', name);
|
||||
} catch {}
|
||||
for (const v of [volume, uploads])
|
||||
for (const v of [volume, ...(bindUploads ? [] : [uploads])])
|
||||
try {
|
||||
docker('volume', 'rm', v);
|
||||
} catch {}
|
||||
if (folder) rmSync(folder, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user