// Run service tests only inside a disposable Linux container or CI job. // Unlike source-tree tests, these load the published entrypoints and dependencies. const fs = require('node:fs/promises'); const sync = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); const net = require('node:net'); const assert = require('node:assert/strict'); const { createHash, randomUUID } = require('node:crypto'); const { createRequire } = require('node:module'); const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); const { setTimeout: delay } = require('node:timers/promises'); const execute = promisify(execFile); const repository = path.resolve(__dirname, '..'); const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8')); const run = (program, args, options = {}) => execute(program, args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000, ...options, }); const requiredFiles = [ '.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml', 'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs', 'shell/upgrade-lock.yaml', 'shell/runtime-guard.cjs', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh', 'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py', 'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py', 'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js', 'sample/config.sample.sh', 'sample/auth.sample.json', 'static/build-info.json', 'static/build/app.js', 'static/build/token.js', 'static/build/bootstrap/upgradeProbe.js', ...[ 'databaseClient', 'databaseErrors', 'databaseProcess', 'databaseWorker', 'repository', 'schema', 'queries', 'cacheStore', 'statistics', ].map((name) => `static/build/data/${name}.js`), 'static/dist/index.html', ...[ 'upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime', ].map((name) => `cli/src/internal/maintenance/${name}.cjs`), ]; function checkPackedFiles(files) { const names = new Set(files.map((file) => file.path)); for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`); for (const name of names) { assert.ok( /^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test( name, ), `Unexpected npm file: ${name}`, ); assert.ok( !/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test( name, ), `Generated or release-only runtime file: ${name}`, ); assert.ok( !/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name, ); assert.notEqual(name, 'sample/tool.ts'); } } async function packPanel(output) { const git = (...args) => run('git', args, { cwd: repository }); assert.equal( (await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout', ); await run( process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository }, ); await fs.mkdir(output, { recursive: true }); const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-')); try { const result = await run( 'npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository }, ); const [manifest] = JSON.parse(result.stdout); checkPackedFiles(manifest.files); assert.equal(manifest.name, '@whyour/qinglong'); const build = await read(path.join(repository, 'static/build-info.json')); const names = new Set(manifest.files.map((file) => file.path)); for (const name of Object.keys(build.files)) assert.ok( names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`, ); const receipt = { filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))), sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length, }; await fs.writeFile( path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2), ); console.log(JSON.stringify(receipt)); return receipt; } finally { await fs.rm(cache, { recursive: true, force: true }); // Also recover an interrupted npm pack whose postpack hook did not execute. await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true, }); } } async function unusedPort() { const server = net.createServer(); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const port = server.address().port; await new Promise((resolve) => server.close(resolve)); return port; } // Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle. async function platformFixture(root, directory, expectedCommit) { assert.ok( directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output', ); const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'), ); assert.equal(names.length, 1, 'Expected one official platform package'); const archive = path.resolve(directory, names[0]); const record = await read(archive + '.json'); const bytes = await fs.readFile(archive); assert.equal(record.format, 2); assert.equal(record.sourceCommit, expectedCommit); assert.equal(record.filename, names[0]); assert.equal(record.sha256, hash(bytes)); assert.equal(record.size, bytes.length); assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/); const local = createRequire(path.join(root, 'package.json')); const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs'); assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget())); return { ...record, archive }; } async function legacyArchive(temp, env) { const directory = path.join(temp, 'legacy'); await fs.mkdir(directory); const result = await run( 'npm', [ 'pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache'), ], { cwd: temp, env, timeout: 300000 }, ); const [record] = JSON.parse(result.stdout); assert.equal(record.name, '@whyour/qinglong'); assert.equal(record.version, '2.23.0'); assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz'); const archive = path.join(directory, record.filename); const integrity = 'sha512-' + createHash('sha512') .update(await fs.readFile(archive)) .digest('base64'); assert.equal( integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed', ); return { archive, integrity, version: record.version }; } async function verifyPanel(input, output) { assert.equal(process.platform, 'linux', 'Service tests require Linux'); assert.equal( process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1', ); const stat = await fs.stat(input); if (stat.isDirectory()) { const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'), ); assert.equal(names.length, 1, 'Expected one exact npm archive'); input = path.join(input, names[0]); } const expected = await read( path.join(path.dirname(input), 'package-receipt.json'), ); assert.equal(path.basename(input), expected.filename); assert.equal( hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt', ); assert.equal((await fs.stat(input)).size, expected.size); assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/); if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA); const nodeSha = hash(await fs.readFile(process.execPath)); const pm2 = process.env.QL_PANEL_TEST_PM2; assert.ok( pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable', ); const temp = await fs.realpath( await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')), ); const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data'); await fs.mkdir(home); await fs.mkdir(data); const port = await unusedPort(); let grpcPort = await unusedPort(); while (grpcPort === port) grpcPort = await unusedPort(); const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'), QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false', BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort), BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1', NODE_PATH: '', NODE_OPTIONS: '', PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join( path.delimiter, ), }; for (const key of [ 'QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL', ]) delete env[key]; let root, fixtureServer, token; const api = async (route, method = 'GET', body, anonymous = false) => { const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, { method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000), }); const result = await response.json(); try { assert.equal(result.code, 200, `${method} ${route}: ${result.message}`); } catch (error) { error.status = response.status; throw error; } return result.data; }; const healthy = async () => { const deadline = Date.now() + 120000; while (Date.now() < deadline) { try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {} await delay(300); } throw new Error('Installed npm backend did not become healthy'); }; const job = async (id, status) => { const deadline = Date.now() + 240000; while (Date.now() < deadline) { try { const value = await api('system/update-status'); assert.equal(value.id, id); if (value.status === status) return value; if (value.status === 'failed' && status !== 'failed') throw new Error(value.error); } catch (error) { if ( error.status !== 503 && !/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test( error.message, ) ) throw error; } await delay(250); } throw new Error(`Installed npm update never reached ${status}`); }; try { const install = (archive) => run( 'npm', [ 'install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive, ], { cwd: temp, env, timeout: 600000 }, ); const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000, }); const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env }); console.log( `Installing actual npm archive with production lifecycle scripts on ${process.version}`, ); await install(input); const globalRoot = ( await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env, }) ).stdout.trim(); root = path.join(globalRoot, '@whyour/qinglong'); env.QL_DIR = root; const runtimeProbes = []; const checkRuntime = async () => { const result = await run( process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env }, ); runtimeProbes.push(JSON.parse(result.stdout.trim())); }; await checkRuntime(); // Use a subprocess: cached candidate modules must not mask missing files // after old-package installation and the real npm overwrite below. await run( process.execPath, [ '-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root, ], { env, cwd: temp }, ); env.QL_DATA_DIR = path.join(temp, 'fresh/data'); await fs.mkdir(env.QL_DATA_DIR, { recursive: true }); console.log('Starting the freshly installed npm package'); await start(); const fresh = await healthy(); assert.equal(fresh.build.sourceCommit, expected.sourceCommit); assert.equal(fresh.services.grpc, true); assert.match( await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), / { assert.equal((await api('user')).username, credentials.username); assert.ok( (await api('envs')).some( (x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce, ), ); assert.equal((await api(`crons/${cron.id}`)).command, cron.command); assert.equal(hash(await fs.readFile(userConfig)), userConfigSha); assert.equal( await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`, ); }; await preservedData(); console.log( 'Stopping legacy service and verifying npm automatically preserves its .env during overwrite', ); await stop(); await fs.appendFile( path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`, ); const savedEnv = await fs.readFile(path.join(root, '.env')); await install(input); assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv); assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600); const configSha = hash(savedEnv); const local = createRequire(path.join(root, 'package.json')); const artifacts = local('./shell/upgrade.cjs'); const build = await artifacts.verifyUpgrade( root, path.join(root, 'static'), ); assert.equal(build.sourceCommit, expected.sourceCommit); assert.notEqual( hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha, ); assert.equal( hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256, ); const fixture = await platformFixture( root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit, ); let badChecksum = true, requests = 0; fixtureServer = http.createServer((request, response) => { requests++; if (request.url === '/latest.json') { response.setHeader('Content-Type', 'application/json'); response.end( JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${ fixtureServer.address().port }/bundle.tar.gz`, }, }, }), ); } else if (request.url === '/bundle.tar.gz') { response.setHeader('Content-Length', fixture.size); sync.createReadStream(fixture.archive).pipe(response); } else { response.statusCode = 404; response.end(); } }); await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve), ); env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${ fixtureServer.address().port }/latest.json`; console.log( 'Starting the overwritten npm package against legacy user data', ); await start(); const original = await healthy(); assert.equal(original.build.sourceCommit, build.sourceCommit); assert.equal(original.services.grpc, true); token = (await api('user/login', 'POST', credentials, true)).token; const preserved = async () => { await preservedData(); assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha); }; await preserved(); console.log('Testing a rejected download through the installed panel API'); const rejected = await api('system/update', 'PUT'); assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i); assert.equal((await healthy()).build.instanceId, original.build.instanceId); await preserved(); badChecksum = false; console.log( 'Testing installed panel download, prestart and explicit application', ); const accepted = await api('system/update', 'PUT'); const ready = await job(accepted.id, 'ready'); assert.equal((await healthy()).build.instanceId, original.build.instanceId); const pointer = await read( path.join(root, '.tmp/upgrade-ready-develop.json'), ); const receipt = await read( path.join(root, '.tmp', pointer.directory, 'prestart.json'), ); assert.ok( receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart', ); const applying = await api('update/system', 'PUT'); await job(applying.id, 'completed'); const afterOnline = await healthy(); await checkRuntime(); assert.notEqual(afterOnline.build.instanceId, original.build.instanceId); assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit); await fs.access(path.join(root, 'node_modules/.pnpm')); assert.match( await fs.realpath(path.join(root, 'node_modules/@mikro-orm/sql')), /[/\\]node_modules[/\\]\.pnpm[/\\]/, ); await assert.rejects(fs.access(path.join(root, 'node_modules/sqlite3')), { code: 'ENOENT', }); await assert.rejects(fs.access(path.join(root, 'node_modules/sequelize')), { code: 'ENOENT', }); await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs')); await artifacts.verifyUpgrade(root, path.join(root, 'static')); assert.match( await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), / ''); if (text) console.error(text.slice(-6000)); } } throw error; } finally { if (fixtureServer) { fixtureServer.closeAllConnections(); await new Promise((resolve) => fixtureServer.close(resolve)); } // A failed assertion must not remove a prefix that its detached updater is // still extracting into. Identify only this private installation's job. if (root) { const state = await read( path.join(root, '.tmp/panel-upgrade.json'), ).catch(() => undefined); if ( Number.isSafeInteger(state?.pid) && state.pid > 1 && typeof state.id === 'string' ) { const command = ( await fs .readFile(`/proc/${state.pid}/cmdline`, 'utf8') .catch(() => '') ).split('\0'); if ( command.includes( path.join(root, 'cli/src/internal/maintenance/panelUpgrade.cjs'), ) && command.includes('worker') && command.includes(state.id) ) { const signal = (name) => { try { process.kill(-state.pid, name); return true; } catch (error) { if (error.code !== 'ESRCH') throw error; return false; } }; signal('SIGTERM'); const deadline = Date.now() + 30000; while (signal(0) && Date.now() < deadline) await delay(100); if (signal(0)) signal('SIGKILL'); } } } await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {}); await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {}); await fs.rm(temp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100, }); } } module.exports = { checkPackedFiles, packPanel, verifyPanel }; if (require.main === module) { const [action, input, output] = process.argv.slice(2); const operation = action === 'pack' && input ? packPanel(path.resolve(input)) : action === 'verify' && input ? verifyPanel(path.resolve(input), output && path.resolve(output)) : Promise.reject( new Error( 'Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]', ), ); operation.catch((error) => { console.error(error); process.exitCode = 1; }); }