Files
BetterDesk/web-nodejs/tests/dockerBuildContract.test.js
T
UNITRONIX b6d968a355 docs(docker): clarify split VPS WSS and Client API routing
Map source-build vs GHCR updates, panel vs Client API ports, and
initiator_token_rejected diagnostics for reverse-proxy Docker installs
(Discussion #452).

Refs #452

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
2026-10-21 01:42:00 +02:00

257 lines
11 KiB
JavaScript

'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:-}');
});
});
describe('Docker compose layout contract (Discussion #452)', () => {
const sourceBuildCompose = ['docker-compose.yml', 'docker-compose.single.yml'];
const ghcrQuickCompose = [
'docker-compose.quick.yml',
'docker-compose.quick.single.yml',
'docker-compose.quick.macvlan.yml',
'docker-compose.quick.single.macvlan.yml',
];
test.each(sourceBuildCompose)('%s is source-build (local tags, pull never)', composeFile => {
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
expect(contents).toMatch(/pull_policy:\s*never/);
expect(contents).toMatch(/image:\s*betterdesk(?:-server|-console)?:local/);
expect(contents).toMatch(/BUILD FROM SOURCE|source build|builds images locally|pull_policy:\s*never/i);
expect(contents).toMatch(/docker compose up -d --build|up -d --build/);
// Must not tell operators that bare `docker compose pull` refreshes *:local
expect(contents).toMatch(/pull.*does NOT|pull_policy:\s*never|does nothing/i);
});
test.each(ghcrQuickCompose)('%s is prebuilt GHCR (pull refreshes images)', composeFile => {
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
// Ignore comments — headers may mention source-build pull_policy: never for contrast.
const yamlBody = contents
.split(/\r?\n/)
.filter(line => !/^\s*#/.test(line))
.join('\n');
expect(contents).toMatch(/ghcr\.io\/unitronix\/betterdesk/);
expect(contents).toMatch(/docker compose pull/);
expect(yamlBody).not.toMatch(/pull_policy:\s*never/);
expect(yamlBody).not.toMatch(/image:\s*betterdesk(?:-server|-console)?:local/);
});
test('split source-build compose documents Client API vs panel ports', () => {
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.yml'), 'utf8');
expect(contents).toContain('21114:21114');
expect(contents).toContain('21118:21118');
expect(contents).toContain('21119:21119');
expect(contents).toContain('5000:5000');
expect(contents).toContain('21121:21121');
expect(contents).toMatch(/:21114/);
expect(contents).toMatch(/Client API|Go Client API|Go HTTP API/);
expect(contents).toMatch(/web panel|Web Console|admin panel/i);
expect(contents).toMatch(/21118|WebSocket Signal/);
expect(contents).toMatch(/21119|WebSocket Relay/);
});
test('split GHCR compose documents preferred Client API on 21114', () => {
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.quick.yml'), 'utf8');
expect(contents).toContain('21114:21114');
expect(contents).toContain('21118:21118');
expect(contents).toContain('21119:21119');
expect(contents).toMatch(/:21114/);
expect(contents).toMatch(/do NOT point RustDesk API Server|not the console port/i);
expect(contents).toContain('DB_URL=/opt/rustdesk/db_v2.sqlite3');
expect(contents).toContain('DB_PATH=/opt/rustdesk/db_v2.sqlite3');
expect(contents).toContain('PANEL_SIGNAL_PROXY_CIDRS');
});
test('single-container quick compose exposes Client API on 21121', () => {
const contents = fs.readFileSync(
path.join(repoRoot, 'docker-compose.quick.single.yml'),
'utf8'
);
expect(contents).toContain('21121:21121');
expect(contents).toContain('5000:5000');
expect(contents).toContain('21118:21118');
expect(contents).toContain('21119:21119');
expect(contents).toMatch(/HTTP API|Client API|RustDesk client/);
});
test('production defaults discourage open legacy outbound on source-build split', () => {
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.yml'), 'utf8');
expect(contents).toContain('ENCRYPTED_ONLY=1');
expect(contents).toMatch(/ALLOW_LEGACY_OUTBOUND=\$\{ALLOW_LEGACY_OUTBOUND:-N\}/);
});
});