'use strict'; const fs = require('fs'); const path = require('path'); const repoRoot = path.resolve(__dirname, '..', '..'); const dockerfiles = ['Dockerfile', 'Dockerfile.console', 'Dockerfile.server']; function copySources(dockerfile) { return fs.readFileSync(path.join(repoRoot, dockerfile), 'utf8') .split(/\r?\n/) .filter(line => /^\s*COPY\s/.test(line) && !line.includes('--from=')) .flatMap(line => { const tokens = line.trim().split(/\s+/).slice(1); return tokens.slice(0, -1); }); } function sourceExists(source) { if (!/[?*[\]{}]/.test(source)) { return fs.existsSync(path.join(repoRoot, source)); } const wildcardIndex = source.search(/[?*[\]{}]/); const directory = path.dirname(source.slice(0, wildcardIndex)); const prefix = path.basename(source.slice(0, wildcardIndex)); const absoluteDirectory = path.join(repoRoot, directory); return fs.existsSync(absoluteDirectory) && fs.readdirSync(absoluteDirectory).some(entry => entry.startsWith(prefix)); } describe('Docker build context contract', () => { test.each(dockerfiles)('%s only copies sources present in the repository', dockerfile => { const missing = copySources(dockerfile).filter(source => !sourceExists(source)); expect(missing).toEqual([]); }); test.each(dockerfiles)('%s does not require withdrawn agent sources', dockerfile => { const contents = fs.readFileSync(path.join(repoRoot, dockerfile), 'utf8'); expect(contents).not.toMatch(/betterdesk-(?:support-)?agent|agent-source/); }); }); describe('Docker admin bootstrap contract', () => { const composeFiles = [ 'docker-compose.yml', 'docker-compose.single.yml', 'docker-compose.quick.yml', 'docker-compose.quick.single.yml', 'docker-compose.quick.macvlan.yml', 'docker-compose.quick.single.macvlan.yml', ]; test.each(composeFiles)('%s forwards all admin password aliases', composeFile => { const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8'); expect(contents).toContain('ADMIN_PASSWORD=${ADMIN_PASSWORD:-}'); expect(contents).toContain('INIT_ADMIN_PASS=${ADMIN_PASSWORD:-}'); expect(contents).toContain('DEFAULT_ADMIN_PASSWORD=${ADMIN_PASSWORD:-}'); }); test('split Compose variants use the shared primary SQLite path', () => { for (const composeFile of ['docker-compose.quick.yml', 'docker-compose.quick.macvlan.yml']) { const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8'); expect(contents).toContain('DB_URL=/opt/rustdesk/db_v2.sqlite3'); expect(contents).toContain('DB_PATH=/opt/rustdesk/db_v2.sqlite3'); } }); test('split server entrypoint normalizes the Go database path', () => { const contents = fs.readFileSync( path.join(repoRoot, 'docker', 'server-entrypoint.sh'), 'utf8' ); expect(contents).toContain('export DB_URL="${DB_PATH:-${RUSTDESK_PATH:-$DATA_DIR}/db_v2.sqlite3}"'); expect(contents).toContain('export DB_URL="${DATABASE_URL:-${DB_PATH:-${RUSTDESK_PATH:-$DATA_DIR}/db_v2.sqlite3}}"'); }); test('hardened Docker checks app-owned paths as betterdesk', () => { const bootstrap = fs.readFileSync( path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'), 'utf8' ); const entrypoint = fs.readFileSync( path.join(repoRoot, 'docker', 'entrypoint.sh'), 'utf8' ); const guard = fs.readFileSync( path.join(repoRoot, 'docker', 'guard-sqlite-auth-split.sh'), 'utf8' ); expect(bootstrap).toContain('file_exists_as_betterdesk()'); expect(bootstrap).toContain('path_exists_as_betterdesk()'); expect(bootstrap).toContain('file_exists_as_betterdesk "$_file"'); expect(bootstrap).toContain('file_exists_as_betterdesk "$_primary_db"'); expect(bootstrap).toContain('path_exists_as_betterdesk "$CREDS_FILE"'); expect(entrypoint).toContain('file_exists_as_betterdesk "$API_KEY_FILE"'); expect(entrypoint).toContain('file_exists_as_betterdesk "$ENROLLMENT_SENTINEL"'); // Guard is self-contained for unit tests, but still prefers the // betterdesk-owned helper when bootstrap has already defined it. expect(guard).toContain('_guard_file_exists()'); expect(guard).toContain('file_exists_as_betterdesk "$1"'); expect(guard).toContain('_guard_run_as_betterdesk test -f "$1"'); expect(guard).toContain('_guard_file_exists "$_auth_db"'); expect(guard).toContain('_guard_file_exists "$_primary_db"'); }); test('Docker entrypoints source path helpers before using them', () => { for (const entrypoint of [ 'docker/server-entrypoint.sh', 'docker/console-entrypoint.sh', 'docker/entrypoint.sh', ]) { const contents = fs.readFileSync(path.join(repoRoot, entrypoint), 'utf8'); const bootstrapSource = contents.indexOf('. /docker/bootstrap-admin-credentials.sh'); const firstHelperUse = contents.indexOf('file_exists_as_betterdesk '); expect(bootstrapSource).toBeGreaterThanOrEqual(0); expect(firstHelperUse === -1 || firstHelperUse > bootstrapSource).toBe(true); } }); test('Docker entrypoints source bootstrap before the SQLite guard', () => { for (const entrypoint of ['docker/console-entrypoint.sh', 'docker/entrypoint.sh']) { const contents = fs.readFileSync(path.join(repoRoot, entrypoint), 'utf8'); const bootstrapSource = contents.indexOf('. /docker/bootstrap-admin-credentials.sh'); const guardSource = contents.indexOf('. /docker/guard-sqlite-auth-split.sh'); expect(bootstrapSource).toBeGreaterThanOrEqual(0); expect(guardSource).toBeGreaterThan(bootstrapSource); } }); test('AIO supervisord admin variables are always exported by bootstrap', () => { const bootstrap = fs.readFileSync( path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'), 'utf8' ); const supervisor = fs.readFileSync( path.join(repoRoot, 'docker', 'supervisord.conf'), 'utf8' ); expect(bootstrap).toContain('ensure_admin_exports()'); expect(bootstrap).toContain('export INIT_ADMIN_USER='); expect(bootstrap).toContain('export INIT_ADMIN_PASS='); expect(bootstrap).toContain('export DEFAULT_ADMIN_USERNAME='); expect(bootstrap).toContain('export DEFAULT_ADMIN_PASSWORD='); expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_USERNAME)s'); expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_PASSWORD)s'); expect(supervisor).toContain('%(ENV_INIT_ADMIN_USER)s'); expect(supervisor).toContain('%(ENV_INIT_ADMIN_PASS)s'); }); test.each([ 'docker-compose.yml', 'docker-compose.single.yml', 'docker-compose.quick.yml', 'docker-compose.quick.single.yml', 'docker-compose.quick.macvlan.yml', 'docker-compose.quick.single.macvlan.yml', ])('%s exposes optional TRUST_PROXY configuration', composeFile => { const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8'); expect(contents).toContain('TRUST_PROXY=${TRUST_PROXY:-}'); }); });