mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 01:27:39 +08:00
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon. Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility. Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
243 lines
8.4 KiB
JavaScript
243 lines
8.4 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const load = require('../helpers/load-security-module.cjs');
|
|
const types = { nodejs: 0, python3: 1, linux: 2 };
|
|
const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 };
|
|
const util = load('back/config/util.ts', {
|
|
'./index': {},
|
|
'./const': {},
|
|
'../loaders/logger': {},
|
|
'../shared/utils': {},
|
|
'../data/dependence': { DependenceTypes: types },
|
|
});
|
|
function serviceFor(docs) {
|
|
const probes = [],
|
|
removed = [],
|
|
uninstalls = [];
|
|
const Service = load('back/services/dependence.ts', {
|
|
'../config': {},
|
|
'../data/dependence': {
|
|
Dependence: class {},
|
|
DependenceTypes: types,
|
|
DependenceStatus: statuses,
|
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
|
DependenceModel: {
|
|
find: async (where, read = {}) => {
|
|
return docs.filter(
|
|
(d) =>
|
|
(where.status === undefined || d.status === where.status) &&
|
|
(!where.id || where.id.includes(d.id)),
|
|
);
|
|
},
|
|
nativeUpdate: async (where, values) => {
|
|
docs
|
|
.filter((d) => where.id.includes(d.id))
|
|
.forEach((d) => Object.assign(d, values));
|
|
},
|
|
nativeDelete: async (where, read = {}) => {
|
|
return removed.push(...where.id);
|
|
},
|
|
},
|
|
},
|
|
'../config/util': {
|
|
...util,
|
|
concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())),
|
|
getGetCommand: (type, name) => {
|
|
probes.push(name);
|
|
return util.getGetCommand(type, name);
|
|
},
|
|
promiseExecSuccess: async () => '',
|
|
},
|
|
'../shared/pLimit': { removeQueuedDependency() {} },
|
|
'./sock': class {},
|
|
'../shared/i18n': {},
|
|
}).default;
|
|
const service = new Service({}, {});
|
|
service.installDependenceOneByOne = (dependencies, install, force) => {
|
|
uninstalls.push({ ids: dependencies.map((d) => d.id), install, force });
|
|
return Promise.resolve();
|
|
};
|
|
return { service, probes, removed, uninstalls };
|
|
}
|
|
test('listing preserves Python source results and still detects missing named packages', async () => {
|
|
const docs = [
|
|
' requirements with spaces/requirements.txt ',
|
|
'requirements-dev.in',
|
|
'project/pyproject.toml',
|
|
'git+https://example.invalid/repo.git',
|
|
'https://example.invalid/pkg.whl',
|
|
'missing-package',
|
|
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
|
|
const { service, probes } = serviceFor(docs);
|
|
for (let i = 0; i < 2; i++) {
|
|
await service.dependencies({
|
|
searchValue: '',
|
|
type: 'python3',
|
|
status: '',
|
|
});
|
|
}
|
|
assert.deepEqual(
|
|
docs.map((d) => d.status),
|
|
[1, 1, 1, 1, 1, 2],
|
|
);
|
|
assert.deepEqual(probes, ['missing-package']);
|
|
// Source installs must continue to execute pip rather than treating the source as a package name.
|
|
assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""');
|
|
});
|
|
test('deletion cleans previously misclassified Python sources and only queues intended records', async () => {
|
|
const docs = [
|
|
{ id: 1, name: 'requirements.txt', type: 1, status: 2 },
|
|
{ id: 2, name: 'present-package', type: 0, status: 1 },
|
|
{ id: 3, name: 'missing-package', type: 0, status: 2 },
|
|
{ id: 4, name: 'queued/requirements.txt', type: 1, status: 6 },
|
|
{ id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 },
|
|
{ id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 },
|
|
];
|
|
const { service, removed, uninstalls } = serviceFor(docs);
|
|
await service.remove(docs.map((d) => d.id));
|
|
assert.deepEqual(removed, [3, 4, 5]);
|
|
assert.deepEqual(uninstalls, [
|
|
{ ids: [1, 2, 6], install: false, force: false },
|
|
]);
|
|
assert.equal(docs[2].status, 2);
|
|
});
|
|
test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const source =
|
|
'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n';
|
|
const api = load('back/config/util.ts', {
|
|
'fs/promises': { readFile: async () => source },
|
|
'./index': {},
|
|
'./const': {},
|
|
'./share': {},
|
|
'./container': { isInContainer: () => true },
|
|
'../loaders/logger': {},
|
|
'../shared/utils': {},
|
|
'../shared/fileAccess': {},
|
|
'../data/dependence': {},
|
|
});
|
|
const previous = process.env.QL_OS_TYPE;
|
|
let command;
|
|
try {
|
|
process.env.QL_OS_TYPE = 'alpine';
|
|
command = await api.updateLinuxMirrorFile('https://mirror.example/alpine');
|
|
} finally {
|
|
if (previous === undefined) delete process.env.QL_OS_TYPE;
|
|
else process.env.QL_OS_TYPE = previous;
|
|
}
|
|
// Redirect the generated command's paths and executable into the fixture only.
|
|
const file = path.join(root, 'repositories');
|
|
const update = path.join(root, 'updated');
|
|
const sandbox = command
|
|
.replaceAll('/etc/apk/repositories', file)
|
|
.replaceAll('/etc/apk', root);
|
|
execFileSync('/bin/bash', [
|
|
'-c',
|
|
`sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`,
|
|
]);
|
|
assert.ok(fs.existsSync(update));
|
|
assert.equal(
|
|
fs.readFileSync(file, 'utf8'),
|
|
source.replaceAll(
|
|
'https://dl-cdn.alpinelinux.org/alpine',
|
|
'https://mirror.example/alpine',
|
|
),
|
|
);
|
|
});
|
|
test('force deletion removes a source record even when its requirements file is gone', async () => {
|
|
const removed = [],
|
|
updates = [];
|
|
const Service = load('back/services/dependence.ts', {
|
|
'../config': {},
|
|
'../config/util': {
|
|
getUninstallCommand() {
|
|
throw Error('Python dependency file must be inside scripts or repo');
|
|
},
|
|
},
|
|
'../data/dependence': {
|
|
Dependence: class {},
|
|
DependenceTypes: types,
|
|
DependenceStatus: { ...statuses, removeFailed: 5 },
|
|
DependenceModel: {
|
|
nativeDelete: async (where, read = {}) => {
|
|
return removed.push(...where.id);
|
|
},
|
|
nativeUpdate: async (where, values) => {
|
|
return updates.push(values);
|
|
},
|
|
},
|
|
},
|
|
'../shared/pLimit': {
|
|
runDependeny: (_dep, fn) => fn(),
|
|
removeQueuedDependency() {},
|
|
},
|
|
'./sock': class {},
|
|
'../shared/i18n': {},
|
|
}).default;
|
|
const service = new Service({}, {});
|
|
const dep = { id: 1, name: 'gone/requirements.txt', type: 1, status: 2 };
|
|
await service.installOrUninstallDependency(dep, false, false);
|
|
assert.deepEqual(removed, []);
|
|
assert.equal(updates[0].status, 5);
|
|
await service.installOrUninstallDependency(dep, false, true);
|
|
assert.deepEqual(removed, [1]);
|
|
});
|
|
test('Python extras run pip even when the base package version is already installed', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-extra-install-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const marker = path.join(root, 'pip-ran');
|
|
const dep = {
|
|
id: 1,
|
|
name: 'fixture[optional]>=1',
|
|
type: 1,
|
|
status: 6,
|
|
log: [],
|
|
};
|
|
let probes = 0;
|
|
const Service = load('back/services/dependence.ts', {
|
|
'../config': {},
|
|
'../config/util': {
|
|
getInstallCommand: () => `printf installed > '${marker}'`,
|
|
getGetCommand: () => {
|
|
probes++;
|
|
return 'probe';
|
|
},
|
|
promiseExecSuccess: async () => '1.0',
|
|
detectOS: async () => 'Alpine',
|
|
fileExist: async () => false,
|
|
},
|
|
'../data/dependence': {
|
|
Dependence: class {},
|
|
DependenceTypes: types,
|
|
DependenceStatus: { ...statuses, installing: 0, cancelled: 7 },
|
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
|
DependenceModel: {
|
|
find: async (where, read = {}) => {
|
|
return [dep];
|
|
},
|
|
nativeUpdate: async (where, values) => {
|
|
return Object.assign(dep, values);
|
|
},
|
|
},
|
|
},
|
|
'../shared/pLimit': {
|
|
firstDependencyId: 1,
|
|
runDependeny: (_dep, fn) => fn(),
|
|
removeQueuedDependency() {},
|
|
updateDepLog: (fn) => fn(),
|
|
},
|
|
'./sock': class {},
|
|
'../shared/i18n': { t: (text) => text, tf: (text) => text },
|
|
}).default;
|
|
const service = new Service({}, { sendMessage() {} });
|
|
await service.installOrUninstallDependency(dep, true, false);
|
|
assert.equal(probes, 0);
|
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'installed');
|
|
assert.equal(dep.status, 1);
|
|
});
|