mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
feat: add script version history (#3085)
* feat: add script version history * fix: validate canonical save-as paths before backup * fix: stack script history footer on small screens
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const source = path.join(__dirname, '../../back/shared/scriptHistory.ts');
|
||||
const mocks = {
|
||||
'../config/util': {
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
};
|
||||
const { ScriptHistory, SCRIPT_HISTORY_LIMITS } = load(source, mocks);
|
||||
|
||||
async function fixture(t, limits = {}, History = ScriptHistory) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-'));
|
||||
t.after(() => fs.rm(root, { force: true, recursive: true }));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
const history = path.join(root, 'history');
|
||||
await fs.mkdir(scripts);
|
||||
const file = path.join(scripts, 'test.js');
|
||||
await fs.writeFile(file, 'original\n');
|
||||
const service = new History(scripts, history, ['auth.json'], {
|
||||
...SCRIPT_HISTORY_LIMITS,
|
||||
...limits,
|
||||
});
|
||||
return { root, scripts, history, file, service };
|
||||
}
|
||||
|
||||
test('first save, unchanged save, restore and undo restore preserve exact content', async (t) => {
|
||||
const { service, file, history } = await fixture(t);
|
||||
assert.deepEqual((await service.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(history), { code: 'ENOENT' });
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
let list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 1);
|
||||
assert.equal(list.versions[0].source, 'baseline');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions[0].id)).version.content,
|
||||
'original\n',
|
||||
);
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const before = await fs.readFile(archive);
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
assert.deepEqual(
|
||||
await fs.readFile(archive),
|
||||
before,
|
||||
'unchanged save performs no archive write',
|
||||
);
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions[0].source, 'save');
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'second\r\n中文');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions[0].source,
|
||||
'restore',
|
||||
);
|
||||
});
|
||||
|
||||
test('conflict check refuses to overwrite a file changed after preview, including external edits', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const list = await service.list('', 'test.js');
|
||||
await fs.writeFile(file, 'external');
|
||||
await assert.rejects(
|
||||
service.restore('', 'test.js', list.versions[0].id, list.currentHash),
|
||||
{ status: 409 },
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'external');
|
||||
await service.save('', 'test.js', 'after external');
|
||||
const current = await service.list('', 'test.js');
|
||||
assert.equal(current.versions[0].source, 'external');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', current.versions[0].id)).version
|
||||
.content,
|
||||
'external',
|
||||
);
|
||||
});
|
||||
|
||||
test('same-name files are isolated, traversal, blacklist and escaping symlinks are forbidden', async (t) => {
|
||||
const { service, scripts, root } = await fixture(t);
|
||||
await fs.mkdir(path.join(scripts, 'nested'));
|
||||
await fs.writeFile(path.join(scripts, 'nested', 'test.js'), 'nested');
|
||||
await service.save('nested', 'test.js', 'changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
await fs.writeFile(path.join(root, 'outside.js'), 'secret');
|
||||
await fs.symlink(
|
||||
path.join(root, 'outside.js'),
|
||||
path.join(scripts, 'escape.js'),
|
||||
);
|
||||
for (const filename of ['../outside.js', 'auth.json', 'escape.js']) {
|
||||
await assert.rejects(service.save('', filename, 'bad'), { status: 403 });
|
||||
await assert.rejects(service.list('', filename), { status: 403 });
|
||||
}
|
||||
await fs.symlink(
|
||||
path.join(scripts, 'test.js'),
|
||||
path.join(scripts, 'alias.js'),
|
||||
);
|
||||
await service.save('', 'alias.js', 'via alias');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'safe aliases share one canonical history',
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrent saves across service instances serialize snapshots without losing content', async (t) => {
|
||||
const { service, scripts, history, file } = await fixture(t);
|
||||
const other = new ScriptHistory(scripts, history);
|
||||
await Promise.all([
|
||||
service.save('', 'test.js', 'A'),
|
||||
other.save('', 'test.js', 'B'),
|
||||
]);
|
||||
const list = await service.list('', 'test.js');
|
||||
const contents = await Promise.all(
|
||||
list.versions.map((v) =>
|
||||
service.detail('', 'test.js', v.id).then((d) => d.version.content),
|
||||
),
|
||||
);
|
||||
contents.push(await fs.readFile(file, 'utf8'));
|
||||
assert.deepEqual(contents.sort(), ['A', 'B', 'original\n'].sort());
|
||||
});
|
||||
|
||||
test('retention trims old revisions and bounds file count and global compressed storage', async (t) => {
|
||||
const { service, scripts, history } = await fixture(t, {
|
||||
versions: 3,
|
||||
files: 2,
|
||||
totalBytes: 1800,
|
||||
});
|
||||
for (let i = 0; i < 6; i++) await service.save('', 'test.js', `version ${i}`);
|
||||
const list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 3);
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions.at(-1).id)).version
|
||||
.content,
|
||||
'version 2',
|
||||
);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const name = `${i}.js`;
|
||||
await fs.writeFile(
|
||||
path.join(scripts, name),
|
||||
randomBytes(300).toString('hex'),
|
||||
);
|
||||
await service.save('', name, 'new');
|
||||
}
|
||||
const files = await fs.readdir(history);
|
||||
assert.ok(files.length <= 2);
|
||||
let total = 0;
|
||||
for (const name of files)
|
||||
total += (await fs.stat(path.join(history, name))).size;
|
||||
assert.ok(total <= 1800);
|
||||
assert.equal((await service.list('', '4.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('invalid text, oversized files and corrupted archives fail without overwriting scripts', async (t) => {
|
||||
const { service, file, history } = await fixture(t, { fileBytes: 100 });
|
||||
await assert.rejects(service.save('', 'test.js', 'a'.repeat(101)), {
|
||||
status: 413,
|
||||
});
|
||||
await fs.writeFile(file, Buffer.from([0xff, 0xfe]));
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { status: 413 });
|
||||
await fs.writeFile(file, 'before');
|
||||
await service.save('', 'test.js', 'after');
|
||||
await fs.writeFile(
|
||||
path.join(history, (await fs.readdir(history))[0]),
|
||||
'corrupt',
|
||||
);
|
||||
await assert.rejects(service.save('', 'test.js', 'replacement'), {
|
||||
status: 500,
|
||||
});
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'after');
|
||||
});
|
||||
|
||||
test('archive failure prevents overwrite; file-write failure retains snapshot and releases locks', async (t) => {
|
||||
let fail = 'archive';
|
||||
const { ScriptHistory: FailingHistory } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (fail === 'archive' && String(file).endsWith('.pending.tmp')) {
|
||||
throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
}
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (fail === 'script' && String(to).endsWith('test.js'))
|
||||
throw Object.assign(new Error('commit failed'), { code: 'ENOSPC' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, file } = await fixture(t, {}, FailingHistory);
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = 'script';
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = '';
|
||||
await service.save('', 'test.js', 'success');
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'success');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('empty content is a valid recoverable version and missing versions return 404', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', '');
|
||||
await service.save('', 'test.js', 'again');
|
||||
const list = await service.list('', 'test.js');
|
||||
const empty = list.versions[0];
|
||||
assert.equal(empty.size, 0);
|
||||
await service.restore('', 'test.js', empty.id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), '');
|
||||
await assert.rejects(service.detail('', 'test.js', 'unknown'), {
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test('quota eviction never happens on failed preparation or failed commit', async (t) => {
|
||||
let failure = '';
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (
|
||||
failure === 'archive-write' &&
|
||||
String(file).endsWith('.pending.tmp')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
if (
|
||||
failure === 'script-write' &&
|
||||
path.basename(file).startsWith('.ql-save-')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (failure === 'archive-commit' && String(to).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
if (failure === 'script-commit' && String(to).endsWith('.js'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const original = await fs.readFile(archive);
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'other-original');
|
||||
for (const step of [
|
||||
'archive-write',
|
||||
'script-write',
|
||||
'archive-commit',
|
||||
'script-commit',
|
||||
]) {
|
||||
failure = step;
|
||||
await assert.rejects(service.save('', 'other.js', 'other-changed'), {
|
||||
code: step.endsWith('write') ? 'ENOSPC' : 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original, step);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'other-original',
|
||||
);
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
assert.equal(
|
||||
(await fs.readdir(scripts)).length,
|
||||
2,
|
||||
'no script staging leftovers',
|
||||
);
|
||||
}
|
||||
// Updating an existing history also restores its exact prior bytes if the
|
||||
// script rename fails, including revisions that retention would have pruned.
|
||||
failure = 'script-commit';
|
||||
await assert.rejects(service.save('', 'test.js', 'changed'), {
|
||||
code: 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'saved',
|
||||
);
|
||||
failure = '';
|
||||
await service.save('', 'other.js', 'other-changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
});
|
||||
|
||||
test('cleanup errors report a successful save with a warning and are retried on the next save', async (t) => {
|
||||
let failure = false;
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
unlink: async (file) => {
|
||||
if (failure && String(file).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.unlink(file);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'old');
|
||||
failure = true;
|
||||
const result = await service.save('', 'other.js', 'new');
|
||||
assert.equal(result.cleanupPending, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
failure = false;
|
||||
await service.save('', 'other.js', 'next');
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
});
|
||||
|
||||
test('large files can be reduced only after explicit historyless confirmation with a matching hash', async (t) => {
|
||||
const { service, file } = await fixture(t, { fileBytes: 100 });
|
||||
await fs.writeFile(file, 'x'.repeat(101));
|
||||
let confirmation;
|
||||
try {
|
||||
await service.save('', 'test.js', 'small');
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
assert.equal(confirmation.status, 413);
|
||||
assert.match(confirmation.currentHash, /^[a-f0-9]{64}$/);
|
||||
assert.equal((await fs.stat(file)).size, 101);
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: '0'.repeat(64),
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
const result = await service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
});
|
||||
assert.equal(result.historyRecorded, false);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'small');
|
||||
await service.save('', 'test.js', 'normal');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'normal history resumes',
|
||||
);
|
||||
try {
|
||||
await service.save('', 'test.js', 'x'.repeat(101));
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
await fs.writeFile(file, 'someone else changed it');
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'x'.repeat(101), {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
});
|
||||
|
||||
test('atomic saves preserve executable mode and canonical symlink targets', async (t) => {
|
||||
const { service, scripts, file } = await fixture(t);
|
||||
await fs.chmod(file, 0o755);
|
||||
await fs.symlink(file, path.join(scripts, 'alias.js'));
|
||||
await service.save('', 'alias.js', 'new');
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o755);
|
||||
assert.equal(
|
||||
(await fs.lstat(path.join(scripts, 'alias.js'))).isSymbolicLink(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'new');
|
||||
});
|
||||
Reference in New Issue
Block a user