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:
@@ -42,6 +42,7 @@ function fixture(t, api = 'script') {
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../services/script': {},
|
||||
'../services/scriptHistory': {},
|
||||
'../services/config': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
typedi: { Container: { get: () => service } },
|
||||
|
||||
@@ -36,6 +36,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
),
|
||||
jwt: { secret },
|
||||
rootPath: tmp,
|
||||
dataPath: tmp,
|
||||
configPath: path.join(tmp, 'config/'),
|
||||
scriptPath: path.join(tmp, 'scripts/'),
|
||||
uploadPath: path.join(tmp, 'upload'),
|
||||
@@ -92,9 +93,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
'../services/script': class {},
|
||||
'../data/open': {},
|
||||
'../data/system': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
|
||||
},
|
||||
typedi: { Service: () => (x) => x },
|
||||
};
|
||||
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
|
||||
...mocks,
|
||||
@@ -102,8 +101,18 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
}).default;
|
||||
const configService = new Config();
|
||||
mocks['../services/config'] = Config;
|
||||
const historyCache = new Map();
|
||||
const History = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
historyCache,
|
||||
).default;
|
||||
const history = new History();
|
||||
mocks.typedi = {
|
||||
Container: { get: (x) => (x === User ? user : configService) },
|
||||
...mocks.typedi,
|
||||
Container: {
|
||||
get: (x) => (x === User ? user : x === History ? history : configService),
|
||||
},
|
||||
};
|
||||
mocks['../api'] = () => {
|
||||
const router = express.Router();
|
||||
@@ -111,9 +120,11 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
|
||||
router,
|
||||
);
|
||||
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(
|
||||
router,
|
||||
);
|
||||
load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
historyCache,
|
||||
).default(router);
|
||||
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ test('script file operations allow token.json while protecting panel configurati
|
||||
])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
const config = {
|
||||
dataPath: root,
|
||||
scriptPath: path.join(root, 'scripts'),
|
||||
configPath: path.join(root, 'config'),
|
||||
tmpPath: path.join(root, 'tmp'),
|
||||
@@ -42,9 +43,6 @@ test('script file operations allow token.json while protecting panel configurati
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true }),
|
||||
readDir: async () => [],
|
||||
},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./sock': {},
|
||||
'./cron': {},
|
||||
@@ -52,15 +50,28 @@ test('script file operations allow token.json while protecting panel configurati
|
||||
'../shared/pLimit': {},
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
};
|
||||
const cache = new Map();
|
||||
const Script = load(
|
||||
path.join(__dirname, '../../back/services/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
).default;
|
||||
const service = new Script();
|
||||
mocks['../services/script'] = Script;
|
||||
mocks.typedi = { Container: { get: () => service } };
|
||||
const History = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
).default;
|
||||
const history = new History();
|
||||
mocks.typedi = {
|
||||
...mocks.typedi,
|
||||
Container: { get: (type) => (type === History ? history : service) },
|
||||
};
|
||||
const app = express.Router();
|
||||
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(app);
|
||||
load(path.join(__dirname, '../../back/api/script.ts'), mocks, cache).default(
|
||||
app,
|
||||
);
|
||||
const router = app.stack.find((layer) => layer.name === 'router').handle;
|
||||
const invoke = async (method, url, body = {}, query = {}, file) => {
|
||||
const route = router.stack.find(
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
require('reflect-metadata');
|
||||
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 express = require('express');
|
||||
const { errors } = require('celebrate');
|
||||
const { Container } = require('typedi');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { resolveFileAccess } = load(
|
||||
path.join(__dirname, '../../back/shared/fileAccess.ts'),
|
||||
);
|
||||
|
||||
test('script routes save, list, preview and restore, with validation and conflict responses', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-api-'));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
await fs.mkdir(scripts);
|
||||
await fs.writeFile(path.join(scripts, 'test.js'), 'before');
|
||||
const config = {
|
||||
scriptPath: scripts,
|
||||
configPath: path.join(root, 'config'),
|
||||
dataPath: root,
|
||||
bakPath: path.join(root, 'bak'),
|
||||
blackFileList: ['auth.json'],
|
||||
writePathList: [scripts, path.join(root, 'config')],
|
||||
};
|
||||
await fs.mkdir(config.bakPath);
|
||||
await fs.mkdir(config.configPath);
|
||||
let failure = '';
|
||||
let beforeRealpath;
|
||||
class ScriptService {
|
||||
checkFilePath(dir, name) {
|
||||
return resolveFileAccess(scripts, [dir || '', name]);
|
||||
}
|
||||
}
|
||||
const mocks = {
|
||||
'../config': { default: config, __esModule: true },
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
realpath: async (file, ...args) => {
|
||||
if (beforeRealpath) await beforeRealpath(file);
|
||||
return fs.realpath(file, ...args);
|
||||
},
|
||||
copyFile: async (from, to) => {
|
||||
if (failure === 'backup' && path.dirname(to) === config.bakPath)
|
||||
throw Object.assign(new Error('backup denied'), { code: 'EACCES' });
|
||||
return fs.copyFile(from, to);
|
||||
},
|
||||
writeFile: async (file, ...args) => {
|
||||
if (failure === 'history' && String(file).endsWith('.pending.tmp'))
|
||||
throw Object.assign(new Error('history full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
},
|
||||
'../config/util': {
|
||||
rmPath: (file) => fs.rm(file, { force: true, recursive: true }),
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
'../services/script': { default: ScriptService, __esModule: true },
|
||||
'../shared/i18n': { t: (value) => value },
|
||||
};
|
||||
const cache = new Map();
|
||||
const api = load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
const { default: ScriptHistoryService } = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
Container.set(ScriptService, new ScriptService());
|
||||
t.after(() => {
|
||||
Container.remove(ScriptHistoryService);
|
||||
Container.remove(ScriptService);
|
||||
});
|
||||
assert.ok(Container.has(ScriptHistoryService));
|
||||
const historyService = Container.get(ScriptHistoryService);
|
||||
assert.strictEqual(Container.get(ScriptHistoryService), historyService);
|
||||
// An empty history read must not create storage or other persistent resources.
|
||||
assert.deepEqual((await historyService.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(path.join(root, 'script-history')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
api.default(app);
|
||||
app.use(errors());
|
||||
app.use((error, _req, res, _next) =>
|
||||
res.status(500).json({ error: error.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const call = async (url, body, method = 'PUT') => {
|
||||
const res = await fetch(
|
||||
base + url,
|
||||
body && {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
assert.equal(
|
||||
(await call('/scripts', { filename: 'test.js', content: 'after' })).body
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
const list = await call('/scripts/history?filename=test.js');
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const id = list.body.data.versions[0].id;
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'before');
|
||||
assert.equal(preview.body.data.current, 'after');
|
||||
assert.equal(
|
||||
(await call('/scripts/history/restore', { filename: 'test.js', id }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: '0'.repeat(64),
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: preview.body.data.currentHash,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'before',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=..%2Foutside')).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await call('/scripts/history?filename=missing')).status, 404);
|
||||
for (const content of ['debug-C', 'debug-D']) {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename: 'test.js', originFilename: 'test.js', path: '', content },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, true);
|
||||
}
|
||||
const debugList = await call('/scripts/history?filename=test.js');
|
||||
const newest = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${debugList.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(newest.body.data.version.content, 'debug-C');
|
||||
assert.equal(newest.body.data.current, 'debug-D');
|
||||
|
||||
// develop allows script names that are protected only in the config root.
|
||||
await fs.writeFile(path.join(scripts, 'auth.json'), 'script-original');
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename: 'auth.json', path: '', content: `script-${method}` },
|
||||
method,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, true);
|
||||
}
|
||||
const namedHistory = await call('/scripts/history?filename=auth.json');
|
||||
assert.equal(namedHistory.body.data.versions.length, 2);
|
||||
const namedPreview = await call(
|
||||
`/scripts/history/detail?filename=auth.json&id=${namedHistory.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(namedPreview.body.data.version.content, 'script-PUT');
|
||||
assert.equal(namedPreview.body.data.current, 'script-POST');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'auth.json',
|
||||
id: namedHistory.body.data.versions[0].id,
|
||||
expectedHash: namedPreview.body.data.currentHash,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'auth.json'), 'utf8'),
|
||||
'script-PUT',
|
||||
);
|
||||
|
||||
// Both editor PUT and debugger POST support the same explicit opt-out.
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'large.js'),
|
||||
'x'.repeat(1024 * 1024 + 1),
|
||||
);
|
||||
const payload = { filename: 'large.js', path: '', content: 'small' };
|
||||
const rejected = await call('/scripts', payload, method);
|
||||
assert.equal(rejected.status, 413);
|
||||
assert.equal(rejected.body.historyUnavailable, true);
|
||||
assert.equal(
|
||||
(await call('/scripts', { ...payload, skipHistory: true }, method))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
...payload,
|
||||
skipHistory: true,
|
||||
expectedHash: rejected.body.currentHash,
|
||||
},
|
||||
method,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, false);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'large.js'), 'utf8'),
|
||||
'small',
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as rejects paths outside writable roots before backup or history changes',
|
||||
async () => {
|
||||
const outside = path.join(root, 'outside');
|
||||
const sibling = `${scripts}-sibling`;
|
||||
for (const dir of [outside, sibling]) {
|
||||
await fs.mkdir(dir);
|
||||
await fs.writeFile(path.join(dir, 'source.js'), 'outside-source');
|
||||
await fs.writeFile(path.join(dir, 'destination.js'), 'outside-target');
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'boundary-source.js'),
|
||||
'safe-source',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'boundary-target.js'),
|
||||
'safe-target',
|
||||
);
|
||||
await fs.symlink(outside, path.join(scripts, 'outside-link'));
|
||||
for (const escape of [
|
||||
'../outside',
|
||||
'../scripts-sibling',
|
||||
'outside-link',
|
||||
]) {
|
||||
for (const escapedSide of ['source', 'target']) {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename:
|
||||
escapedSide === 'target'
|
||||
? `${escape}/destination.js`
|
||||
: 'boundary-target.js',
|
||||
originFilename:
|
||||
escapedSide === 'source'
|
||||
? `${escape}/source.js`
|
||||
: 'boundary-source.js',
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(result.body.code, 403);
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'boundary-source.js'), 'utf8'),
|
||||
'safe-source',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'boundary-target.js'), 'utf8'),
|
||||
'safe-target',
|
||||
);
|
||||
for (const dir of [outside, sibling]) {
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(dir, 'source.js'), 'utf8'),
|
||||
'outside-source',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(dir, 'destination.js'), 'utf8'),
|
||||
'outside-target',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=boundary-target.js')).body.data
|
||||
.versions.length,
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
for (const swappedSide of ['source', 'target']) {
|
||||
await t.test(
|
||||
`save-as rejects a ${swappedSide} symlink changed during real-path resolution`,
|
||||
async () => {
|
||||
const source = path.join(scripts, `${swappedSide}-race-source.js`);
|
||||
const destination = path.join(scripts, `${swappedSide}-race-target.js`);
|
||||
const outside = path.join(root, `${swappedSide}-race-outside.js`);
|
||||
await fs.writeFile(source, 'safe-source');
|
||||
await fs.writeFile(destination, 'safe-target');
|
||||
await fs.writeFile(outside, 'outside-sentinel');
|
||||
const swapped = swappedSide === 'source' ? source : destination;
|
||||
let swappedOnce = false;
|
||||
beforeRealpath = async (file) => {
|
||||
if (file === swapped && !swappedOnce) {
|
||||
swappedOnce = true;
|
||||
await fs.unlink(swapped);
|
||||
await fs.symlink(outside, swapped);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: path.basename(destination),
|
||||
originFilename: path.basename(source),
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(swappedOnce, true);
|
||||
assert.equal(result.status, 403);
|
||||
assert.equal(await fs.readFile(outside, 'utf8'), 'outside-sentinel');
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
swappedSide === 'source' ? destination : source,
|
||||
'utf8',
|
||||
),
|
||||
swappedSide === 'source' ? 'safe-target' : 'safe-source',
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
await fs.unlink(swapped);
|
||||
await fs.writeFile(
|
||||
swapped,
|
||||
swappedSide === 'source' ? 'safe-source' : 'safe-target',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
`/scripts/history?filename=${path.basename(destination)}`,
|
||||
)
|
||||
).body.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
beforeRealpath = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as rechecks configuration secret names after resolving a changed source link',
|
||||
async () => {
|
||||
const secret = path.join(config.configPath, 'auth.json');
|
||||
const source = path.join(config.configPath, 'ordinary.js');
|
||||
const destination = path.join(scripts, 'config-race-target.js');
|
||||
await fs.writeFile(secret, 'config-secret');
|
||||
await fs.writeFile(source, 'ordinary-config');
|
||||
await fs.writeFile(destination, 'safe-target');
|
||||
let swapped = false;
|
||||
beforeRealpath = async (file) => {
|
||||
if (file === source && !swapped) {
|
||||
swapped = true;
|
||||
await fs.unlink(source);
|
||||
await fs.symlink(secret, source);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: path.basename(destination),
|
||||
originFilename: '../config/ordinary.js',
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(swapped, true);
|
||||
assert.equal(result.status, 403);
|
||||
assert.equal(await fs.readFile(secret, 'utf8'), 'config-secret');
|
||||
assert.equal(await fs.readFile(destination, 'utf8'), 'safe-target');
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=config-race-target.js')).body
|
||||
.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
beforeRealpath = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
for (const scenario of ['target-link', 'source-link', 'normalized-path']) {
|
||||
await t.test(
|
||||
`save-as preserves aliases of the same file: ${scenario}`,
|
||||
async () => {
|
||||
const sourceName = `${scenario}-source.js`;
|
||||
const aliasName = `${scenario}-alias.js`;
|
||||
const sourcePath = path.join(scripts, sourceName);
|
||||
await fs.writeFile(sourcePath, 'source-original');
|
||||
if (scenario !== 'normalized-path')
|
||||
await fs.symlink(sourceName, path.join(scripts, aliasName));
|
||||
const filename =
|
||||
scenario === 'target-link'
|
||||
? aliasName
|
||||
: scenario === 'normalized-path'
|
||||
? `./${sourceName}`
|
||||
: sourceName;
|
||||
const originFilename =
|
||||
scenario === 'source-link' ? aliasName : sourceName;
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename, originFilename, path: '', content: 'source-edited' },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.code, 200);
|
||||
assert.equal(await fs.readFile(sourcePath, 'utf8'), 'source-edited');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, filename), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
if (scenario !== 'normalized-path') {
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(scripts, aliasName)),
|
||||
sourceName,
|
||||
);
|
||||
const sourceHistory = await call(
|
||||
`/scripts/history?filename=${sourceName}`,
|
||||
);
|
||||
const aliasHistory = await call(
|
||||
`/scripts/history?filename=${aliasName}`,
|
||||
);
|
||||
assert.deepEqual(aliasHistory.body.data, sourceHistory.body.data);
|
||||
}
|
||||
const list = await call(
|
||||
`/scripts/history?filename=${encodeURIComponent(filename)}`,
|
||||
);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=${encodeURIComponent(
|
||||
filename,
|
||||
)}&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'source-original');
|
||||
assert.equal(preview.body.data.current, 'source-edited');
|
||||
},
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as backs up the source and retains destination history before removing the source',
|
||||
async () => {
|
||||
await fs.writeFile(path.join(scripts, 'source.js'), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'destination.js'),
|
||||
'destination-original',
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: 'destination.js',
|
||||
originFilename: 'source.js',
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
await assert.rejects(fs.access(path.join(scripts, 'source.js')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(config.bakPath, 'source.js'), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'destination.js'), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
const list = await call('/scripts/history?filename=destination.js');
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=destination.js&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'destination-original');
|
||||
},
|
||||
);
|
||||
for (const step of ['backup', 'history']) {
|
||||
await t.test(
|
||||
`save-as ${step} failure preserves both original scripts`,
|
||||
async () => {
|
||||
const sourceName = `${step}-source.js`,
|
||||
destinationName = `${step}-destination.js`;
|
||||
await fs.writeFile(path.join(scripts, sourceName), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, destinationName),
|
||||
'destination-original',
|
||||
);
|
||||
failure = step;
|
||||
try {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: destinationName,
|
||||
originFilename: sourceName,
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 500);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, sourceName), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, destinationName), 'utf8'),
|
||||
'destination-original',
|
||||
);
|
||||
assert.equal(
|
||||
(await call(`/scripts/history?filename=${destinationName}`)).body
|
||||
.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
failure = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(send) {
|
||||
const dialogs = [],
|
||||
notices = [],
|
||||
calls = [];
|
||||
const request = {};
|
||||
for (const method of ['put', 'post'])
|
||||
request[method] = async (url, body, options) => {
|
||||
calls.push({ method, body });
|
||||
const result = await send(body);
|
||||
if (result.status) return options.onError(result);
|
||||
return result;
|
||||
};
|
||||
const { saveWithHistory } = load(
|
||||
path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'),
|
||||
{
|
||||
'@/utils/http': { request },
|
||||
antd: {
|
||||
Modal: {
|
||||
confirm: (dialog) => {
|
||||
dialogs.push(dialog);
|
||||
return { update: (options) => Object.assign(dialog, options) };
|
||||
},
|
||||
},
|
||||
message: Object.fromEntries(
|
||||
['error', 'warning', 'success'].map((type) => [
|
||||
type,
|
||||
(text) => notices.push({ type, text }),
|
||||
]),
|
||||
),
|
||||
},
|
||||
'react-intl-universal': {
|
||||
__esModule: true,
|
||||
default: { get: (key) => key },
|
||||
},
|
||||
},
|
||||
);
|
||||
return { saveWithHistory, dialogs, notices, calls };
|
||||
}
|
||||
const unavailable = {
|
||||
status: 413,
|
||||
data: { historyUnavailable: true, currentHash: 'a'.repeat(64) },
|
||||
};
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => {
|
||||
const f = fixture(() => unavailable);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {
|
||||
filename: 'large.js',
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.dialogs.length, 1);
|
||||
assert.match(f.dialogs[0].content, /不会保留/);
|
||||
f.dialogs[0].onCancel();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.notices.length, 0);
|
||||
});
|
||||
|
||||
test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { code: 200, data: { historyRecorded: false } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('post', '/api/scripts', {
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal((await pending).code, 200);
|
||||
assert.equal(f.dialogs[0].cancelButtonProps.disabled, true);
|
||||
assert.equal(f.dialogs[0].keyboard, false);
|
||||
assert.equal(f.calls[1].method, 'post');
|
||||
assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash);
|
||||
assert.equal(f.calls[1].body.skipHistory, true);
|
||||
assert.deepEqual(
|
||||
f.notices.map((v) => v.type),
|
||||
['warning'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale confirmation reports the conflict without marking the editor saved', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { status: 409, data: { message: 'changed' } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]);
|
||||
});
|
||||
|
||||
test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => {
|
||||
const failed = fixture(() => ({
|
||||
status: 500,
|
||||
data: { message: 'storage failed' },
|
||||
}));
|
||||
assert.equal(
|
||||
await failed.saveWithHistory('put', '/api/scripts', {}),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(failed.dialogs.length, 0);
|
||||
const committed = fixture(() => ({
|
||||
code: 200,
|
||||
data: { cleanupPending: true },
|
||||
}));
|
||||
assert.equal(
|
||||
(await committed.saveWithHistory('put', '/api/scripts', {})).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(committed.notices[0].type, 'warning');
|
||||
});
|
||||
Reference in New Issue
Block a user