Files
qinglong/test/back/file-access-scope.test.cjs
T

188 lines
6.1 KiB
JavaScript

const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const express = require('express');
const load = require('../helpers/load-security-module.cjs');
function fixture(t, customData = false) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-access-scope-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const data = path.join(root, customData ? 'custom-data' : 'data');
const config = {
rootPath: root,
configPath: path.join(data, 'config'),
scriptPath: path.join(data, 'scripts'),
logPath: path.join(data, 'log'),
blackFileList: ['token.json', 'auth.json', 'grpc', 'env.js'],
};
for (const directory of [
config.configPath,
config.scriptPath,
config.logPath,
])
fs.mkdirSync(directory, { recursive: true });
const secret = path.join(config.configPath, 'token.json');
fs.writeFileSync(secret, 'PANEL-SECRET');
for (const directory of [
config.configPath,
config.scriptPath,
config.logPath,
])
fs.symlinkSync(secret, path.join(directory, 'secret-link'));
const mocks = {
'../config': config,
typedi: { Service: () => (x) => x, Inject: () => () => {} },
'../config/util': {
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
rmPath: (p) => fs.promises.rm(p),
removeAnsi: (s) => s,
},
'../shared/utils': {
writeFileWithLock: (p, s) => fs.promises.writeFile(p, s),
},
'../shared/i18n': { t: (s) => s },
'../config/const': {},
'../data/runningInstance': {
InstanceStatus: { running: 'running' },
RunningInstanceModel: { findOne: async () => null },
},
};
return { config, mocks, secret };
}
function api(name, service, mocks) {
mocks.typedi = { Container: { get: () => service } };
const app = express.Router();
load(path.resolve(`back/api/${name}.ts`), mocks).default(app);
const router = app.stack.find((layer) => layer.name === 'router').handle;
return async (method, url, values) => {
const handler = router.stack
.find((layer) => layer.route?.path === url && layer.route.methods[method])
.route.stack.at(-1).handle;
let result;
await handler(
{ query: values, body: values },
{
send: (value) => {
result = value;
},
download: (p) => {
result = { code: 200, data: fs.readFileSync(p, 'utf8') };
},
},
(error) => {
throw error;
},
);
return result;
};
}
for (const customData of [false, true]) {
test(`config script access scopes blacklist and uses the configured data directory (custom=${customData})`, async (t) => {
const { config, mocks, secret } = fixture(t, customData);
const Config = load(path.resolve('back/services/config.ts'), mocks).default;
mocks['../services/config'] = Config;
const invoke = api('config', new Config(), mocks);
const legacy = path.join(config.rootPath, 'data/scripts');
if (customData) {
fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'notify.py'), 'legacy');
}
for (const filename of [
'token.json',
'auth.json',
'grpc/env.js',
'notify.py',
]) {
const file = path.join(config.scriptPath, filename);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'initial');
const name = `data/scripts/${filename}`;
assert.equal(
(await invoke('get', '/detail', { path: name })).data,
'initial',
);
assert.equal(
(await invoke('post', '/save', { name, content: 'updated' })).code,
200,
);
assert.equal(
(await invoke('get', '/detail', { path: name })).data,
'updated',
);
assert.equal(fs.readFileSync(file, 'utf8'), 'updated');
}
if (customData)
assert.equal(
fs.readFileSync(path.join(legacy, 'notify.py'), 'utf8'),
'legacy',
);
for (const name of [
'token.json',
'grpc/client.key',
'secret-link',
'data/scripts/secret-link',
'data/scripts/../config/token.json',
secret,
]) {
assert.equal(
(await invoke('get', '/detail', { path: name })).code,
403,
name,
);
assert.equal(
(await invoke('post', '/save', { name, content: 'overwrite' })).code,
403,
name,
);
}
assert.equal(
(await invoke('post', '/save', { name: 'normal.txt', content: 'normal' }))
.code,
200,
);
assert.equal(
(await invoke('get', '/detail', { path: 'normal.txt' })).data,
'normal',
);
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
});
}
test('log APIs allow configuration-like names while retaining directory boundaries', async (t) => {
const { config, mocks, secret } = fixture(t);
const Log = load(path.resolve('back/services/log.ts'), mocks).default;
mocks['../services/log'] = Log;
const invoke = api('log', new Log(), mocks);
for (const filename of ['token.json', 'grpc/run.log', 'env.js/run.log']) {
const file = path.join(config.logPath, filename);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'log content');
const values = {
path: path.dirname(filename) === '.' ? '' : path.dirname(filename),
file: path.basename(filename),
filename: path.basename(filename),
};
assert.equal((await invoke('get', '/detail', values)).data, 'log content');
assert.equal(
(await invoke('post', '/download', values)).data,
'log content',
);
assert.equal((await invoke('delete', '/', values)).code, 200);
assert.equal(fs.existsSync(file), false);
}
for (const filename of ['../config/token.json', 'secret-link', secret]) {
const values = { path: '', file: filename, filename };
for (const [method, route] of [
['get', '/detail'],
['post', '/download'],
['delete', '/'],
])
assert.equal((await invoke(method, route, values)).code, 403, filename);
}
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
});