198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
124 lines
3.9 KiB
JavaScript
124 lines
3.9 KiB
JavaScript
/**
|
|
* @file settings-import.test.js
|
|
* @description Restoring a backup: `GET /api/settings/export` writes every table
|
|
* the restore reads (with the format stamp), and `POST /api/settings/import`
|
|
* takes that file as a multipart upload, restores it without overwriting
|
|
* anything, refuses a file that is not an export without echoing its text, and
|
|
* refuses a cross-origin caller.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `settings-import-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function upload(text, headers = {}) {
|
|
const form = new FormData();
|
|
form.append("file", new Blob([text], { type: "application/json" }), "backup.json");
|
|
return fetch(`${BASE}/api/settings/import`, { method: "POST", body: form, headers });
|
|
}
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
db.prepare("INSERT INTO sessions (id, name, status, started_at) VALUES (?, ?, ?, ?)").run(
|
|
"imp-1",
|
|
"Backed up",
|
|
"completed",
|
|
"2026-01-10T10:00:00.000Z"
|
|
);
|
|
db.prepare("INSERT INTO events (session_id, event_type, data) VALUES (?, ?, ?)").run(
|
|
"imp-1",
|
|
"Stop",
|
|
"{}"
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
if (server) server.close();
|
|
if (db) db.close();
|
|
for (const f of [TEST_DB, TEST_DB + "-wal", TEST_DB + "-shm"]) {
|
|
try {
|
|
fs.unlinkSync(f);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("backup and restore", () => {
|
|
let exported;
|
|
|
|
it("the export carries the format stamp and every table the restore reads", async () => {
|
|
const res = await fetch(`${BASE}/api/settings/export`);
|
|
assert.equal(res.status, 200);
|
|
exported = await res.text();
|
|
const bundle = JSON.parse(exported);
|
|
assert.equal(bundle.format, "agenthub-export");
|
|
assert.equal(bundle.version, 1);
|
|
for (const t of [
|
|
"sessions",
|
|
"agents",
|
|
"events",
|
|
"token_usage",
|
|
"workflows",
|
|
"dashboard_runs",
|
|
"alert_rules",
|
|
"model_pricing",
|
|
]) {
|
|
assert.ok(Array.isArray(bundle[t]), `missing ${t}`);
|
|
}
|
|
});
|
|
|
|
it("restores an uploaded export, skipping what is already there", async () => {
|
|
let res = await upload(exported);
|
|
let body = await res.json();
|
|
assert.equal(res.status, 200, JSON.stringify(body));
|
|
assert.equal(body.sessions_imported, 0);
|
|
assert.equal(body.sessions_skipped, 1);
|
|
|
|
db.prepare("DELETE FROM events WHERE session_id = 'imp-1'").run();
|
|
db.prepare("DELETE FROM sessions WHERE id = 'imp-1'").run();
|
|
res = await upload(exported);
|
|
body = await res.json();
|
|
assert.equal(res.status, 200, JSON.stringify(body));
|
|
assert.equal(body.sessions_imported, 1);
|
|
assert.equal(body.events, 1);
|
|
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 'imp-1'").get());
|
|
});
|
|
|
|
it("refuses a file that is not an export, without echoing its text", async () => {
|
|
const res = await upload("root:x:0:0:secret-line");
|
|
const body = await res.json();
|
|
assert.equal(res.status, 400);
|
|
assert.equal(body.error.code, "INVALID_FORMAT");
|
|
assert.doesNotMatch(JSON.stringify(body), /secret-line/);
|
|
|
|
const notBundle = await upload(JSON.stringify({ foo: 1 }));
|
|
assert.equal(notBundle.status, 400);
|
|
});
|
|
|
|
it("asks for a file", async () => {
|
|
const res = await fetch(`${BASE}/api/settings/import`, { method: "POST" });
|
|
assert.equal(res.status, 400);
|
|
assert.equal((await res.json()).error.code, "NO_FILE");
|
|
});
|
|
|
|
it("refuses a cross-origin caller", async () => {
|
|
const res = await upload(exported, { Origin: "https://evil.example" });
|
|
assert.equal(res.status, 403);
|
|
});
|
|
});
|