270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const {
|
|
generateCommandPreview,
|
|
findDynamicSegments,
|
|
normalizePreviewText,
|
|
previewParts
|
|
} = require("../src/services/command-preview");
|
|
const { runAdvancedCommand } = require("../src/services/commands");
|
|
const {
|
|
conditionMatches,
|
|
selectRandomReply,
|
|
validateRandomConfig
|
|
} = require("../src/services/command-random");
|
|
const {
|
|
DELAY_MS,
|
|
issueConfirmation,
|
|
consumeConfirmation,
|
|
isDestructivePath
|
|
} = require("../src/services/destructive-confirm");
|
|
const { db, migrate } = require("../src/services/db");
|
|
|
|
async function run() {
|
|
migrate();
|
|
const columns = db.prepare("PRAGMA table_info(custom_commands)").all().map((row) => row.name);
|
|
for (const column of [
|
|
"preview_text",
|
|
"preview_status",
|
|
"preview_error",
|
|
"preview_generated_at",
|
|
"preview_dynamic_segments",
|
|
"random_replies_json",
|
|
"rng_enabled",
|
|
"rng_min",
|
|
"rng_max"
|
|
]) {
|
|
assert(columns.includes(column), `Missing custom command preview column: ${column}`);
|
|
}
|
|
|
|
const preview = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
function run(ctx) {
|
|
ctx.reply("Hello " + ctx.user.username);
|
|
return "Balance 1234, id " + ctx.user.id + ", date " + new Date().toISOString();
|
|
}
|
|
`
|
|
});
|
|
assert.equal(preview.preview_status, "ready");
|
|
assert.match(preview.preview_text, /Hello some_user/);
|
|
assert.equal(preview.preview_text.includes("Some User"), false);
|
|
const segments = JSON.parse(preview.preview_dynamic_segments);
|
|
for (const type of ["username", "number", "id", "date"]) {
|
|
assert(segments.some((segment) => segment.type === type), `Missing ${type} dynamic segment`);
|
|
}
|
|
assert(previewParts(preview.preview_text, segments).some((part) => part.dynamic));
|
|
assert.equal(normalizePreviewText("Some User and some-user"), "some_user and some_user");
|
|
assert(findDynamicSegments("some_user 42 user_123 2026-01-02T12:34:56.000Z").length, 4);
|
|
const punctuationPreview = "Fun fact for some_user:\nExample";
|
|
assert.equal(
|
|
previewParts(punctuationPreview, findDynamicSegments(punctuationPreview)).map((part) => part.text).join(""),
|
|
punctuationPreview
|
|
);
|
|
|
|
const snippetPreview = await generateCommandPreview({
|
|
language: "js",
|
|
code: 'return "Snippet for " + ctx.user.username;'
|
|
});
|
|
assert.equal(snippetPreview.preview_status, "ready");
|
|
assert.equal(snippetPreview.preview_text, "Snippet for some_user");
|
|
|
|
const exportedPreview = await generateCommandPreview({
|
|
language: "js",
|
|
code: 'module.exports = (ctx) => "Export for " + ctx.user.username;'
|
|
});
|
|
assert.equal(exportedPreview.preview_status, "ready");
|
|
assert.equal(exportedPreview.preview_text, "Export for some_user");
|
|
|
|
const safeBuiltInsPreview = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
const facts = ["using a process called respiration"];
|
|
const selected = facts[Math.floor(Math.random() * facts.length)];
|
|
const url = new URL("https://example.com/facts?count=1");
|
|
return JSON.stringify({ selected, host: url.hostname, count: url.searchParams.get("count") });
|
|
`
|
|
});
|
|
assert.equal(safeBuiltInsPreview.preview_status, "ready");
|
|
assert.deepEqual(JSON.parse(safeBuiltInsPreview.preview_text), {
|
|
selected: "using a process called respiration",
|
|
host: "example.com",
|
|
count: "1"
|
|
});
|
|
|
|
for (const code of [
|
|
'return Math.constructor.constructor("return pro" + "cess")().cwd();',
|
|
'return ctx.constructor.constructor("return pro" + "cess")().cwd();',
|
|
'return URL.constructor.constructor("return pro" + "cess")().cwd();'
|
|
]) {
|
|
const constructorEscape = await generateCommandPreview({ language: "js", code });
|
|
assert.equal(constructorEscape.preview_status, "unavailable");
|
|
assert.match(constructorEscape.preview_error, /code generation from strings disallowed/i);
|
|
}
|
|
|
|
const pythonSnippetPreview = await generateCommandPreview({
|
|
language: "python",
|
|
code: 'return "Python snippet"'
|
|
});
|
|
assert.equal(pythonSnippetPreview.preview_status, "ready");
|
|
assert.equal(pythonSnippetPreview.preview_text, "Python snippet");
|
|
|
|
assert.equal(await runAdvancedCommand({ language: "js", code: 'return "wrapped";' }, {}), "wrapped");
|
|
|
|
const randomConfig = {
|
|
replies: [
|
|
{ text: "Low {{core.command.rng}}", weight: 1, condition: "<20" },
|
|
{ text: "High", weight: 2, condition: ">=20" }
|
|
],
|
|
rngEnabled: true,
|
|
rngMin: 1,
|
|
rngMax: 100
|
|
};
|
|
const rolls = [0.1, 0.9];
|
|
const selectedRandom = selectRandomReply(randomConfig, () => rolls.shift());
|
|
assert.equal(selectedRandom.rng, 11);
|
|
assert.equal(selectedRandom.reply.text, "Low {{core.command.rng}}");
|
|
assert.equal(conditionMatches(">=10", 10), true);
|
|
assert.equal(conditionMatches("<20", 20), false);
|
|
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1000 }], rngMin: 1, rngMax: 100 }).ok, false);
|
|
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1, condition: "maybe" }], rngEnabled: true }).ok, false);
|
|
|
|
const noSideEffects = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
async function run(ctx) {
|
|
await ctx.economy.add(ctx.user.id, 999999);
|
|
await ctx.inventory.remove(ctx.user.id, "all");
|
|
await ctx.moderation.ban(ctx.user.id);
|
|
await ctx.files.write("outside.txt", "blocked");
|
|
ctx.db.prepare("DELETE FROM users").run();
|
|
return "Balance " + await ctx.economy.getBalance(ctx.user.id);
|
|
}
|
|
`
|
|
});
|
|
assert.equal(noSideEffects.preview_status, "ready");
|
|
assert.equal(noSideEffects.preview_text, "Balance 1234");
|
|
|
|
const readOnlyFetch = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
async function run() {
|
|
const response = await fetch("data:application/json,%7B%22message%22%3A%22preview%22%7D");
|
|
const data = await response.json();
|
|
return data.message;
|
|
}
|
|
`
|
|
});
|
|
assert.equal(readOnlyFetch.preview_status, "ready");
|
|
assert.equal(readOnlyFetch.preview_text, "preview");
|
|
|
|
const blockedNetworkFetch = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
async function run() {
|
|
await fetch("http://127.0.0.1:3000/admin/settings");
|
|
return "no";
|
|
}
|
|
`
|
|
});
|
|
assert.equal(blockedNetworkFetch.preview_status, "unavailable");
|
|
assert.match(blockedNetworkFetch.preview_error, /does not send external network requests/i);
|
|
|
|
const blockedWriteFetch = await generateCommandPreview({
|
|
language: "js",
|
|
code: `
|
|
async function run() {
|
|
await fetch("data:text/plain,no", { method: "POST", body: "write" });
|
|
return "no";
|
|
}
|
|
`
|
|
});
|
|
assert.equal(blockedWriteFetch.preview_status, "unavailable");
|
|
assert.match(blockedWriteFetch.preview_error, /read-only GET and HEAD/i);
|
|
|
|
const blocked = await generateCommandPreview({
|
|
language: "js",
|
|
code: "function run() { return process.cwd(); }"
|
|
});
|
|
assert.equal(blocked.preview_status, "unavailable");
|
|
assert.match(blocked.preview_error, /blocks filesystem, process/i);
|
|
|
|
const blockedImport = await generateCommandPreview({
|
|
language: "python",
|
|
code: "import os\ndef run(ctx):\n return os.getcwd()"
|
|
});
|
|
assert.equal(blockedImport.preview_status, "unavailable");
|
|
assert.match(blockedImport.preview_error, /blocks imports/i);
|
|
|
|
const timedOut = await generateCommandPreview({
|
|
language: "js",
|
|
code: "function run() { while (true) {} }"
|
|
});
|
|
assert.equal(timedOut.preview_status, "unavailable");
|
|
assert.match(timedOut.preview_error, /timed out/i);
|
|
|
|
const req = fakeRequest();
|
|
assert.equal(isDestructivePath("/admin/commands/1/delete"), true);
|
|
assert.equal(isDestructivePath("/admin/commands/1/update"), false);
|
|
const first = issueConfirmation(req, "/admin/commands/1/delete");
|
|
assert.equal(first.delay_seconds, DELAY_MS / 1000);
|
|
assert.equal(consumeConfirmation(req, "/admin/commands/1/delete", first.token).reason, "too_early");
|
|
|
|
const second = issueConfirmation(req, "/admin/commands/1/delete");
|
|
req.session.destructive_confirmations[second.token].not_before = Date.now() - 1;
|
|
assert.equal(consumeConfirmation(req, "/admin/pages/1/delete", second.token).reason, "action_mismatch");
|
|
|
|
const third = issueConfirmation(req, "/admin/commands/1/delete");
|
|
req.session.destructive_confirmations[third.token].not_before = Date.now() - 1;
|
|
assert.equal(consumeConfirmation(req, "/admin/commands/1/delete", third.token).valid, true);
|
|
assert.equal(consumeConfirmation(req, "/admin/commands/1/delete", third.token).valid, false);
|
|
|
|
const appScript = fs.readFileSync(path.join(__dirname, "..", "src", "web", "public", "app.js"), "utf8");
|
|
const layout = fs.readFileSync(path.join(__dirname, "..", "src", "web", "views", "partials", "layout-bottom.ejs"), "utf8");
|
|
const commandView = fs.readFileSync(path.join(__dirname, "..", "src", "web", "views", "admin-commands.ejs"), "utf8");
|
|
assert(appScript.includes("${confirmLabel(form, submitter)} in ${remaining}"));
|
|
assert(appScript.includes('button.disabled = remaining > 0'));
|
|
assert(appScript.includes('fetch("/api/destructive-confirmations"'));
|
|
assert(appScript.includes("destructiveToken: timedDestructiveToken"));
|
|
assert(appScript.includes("destructiveFetch(action"));
|
|
assert(appScript.includes('headers.set("X-Confirmation-Token", token)'));
|
|
assert(appScript.includes("event.preventDefault();"));
|
|
assert(appScript.includes("expiryTimer"));
|
|
assert(appScript.includes("resetDestructive"));
|
|
assert(layout.includes("data-destructive-modal"));
|
|
assert(layout.includes("data-destructive-confirm disabled"));
|
|
assert(commandView.includes("Preview unavailable"));
|
|
assert(commandView.includes("preview-dynamic"));
|
|
assert(commandView.includes('data-collapsed-label="Read more"'));
|
|
assert(commandView.includes('data-expanded-label="Read less"'));
|
|
assert.equal(commandView.includes("Full preview"), false);
|
|
assert.equal(commandView.includes("command-preview-full"), false);
|
|
assert(appScript.includes('[data-expand-text]'));
|
|
assert(commandView.includes(">Static<"));
|
|
assert(commandView.includes(">Random Reply<"));
|
|
assert(commandView.includes(">Dynamic<"));
|
|
assert(commandView.includes("random_weight"));
|
|
assert(commandView.includes("{{core.command.rng}}"));
|
|
assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false);
|
|
|
|
console.log("Command preview and destructive confirmation verification passed.");
|
|
}
|
|
|
|
function fakeRequest() {
|
|
return {
|
|
session: {
|
|
user: { id: "admin-user", isAdmin: true }
|
|
},
|
|
body: {},
|
|
get() {
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|