449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||
import request from "supertest";
|
||
import fs from "fs";
|
||
import path from "path";
|
||
import os from "os";
|
||
import JSZip from "jszip";
|
||
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
|
||
|
||
type LegacyDbOptions = {
|
||
tableStyle: "prisma" | "plural-lower";
|
||
includeCollections: boolean;
|
||
includeMigrationsTable: boolean;
|
||
includeTrashDrawing: boolean;
|
||
};
|
||
|
||
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "excalidash-legacy-"));
|
||
|
||
const openWritableDb = (filePath: string): any => {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||
const { DatabaseSync } = require("node:sqlite") as any;
|
||
return new DatabaseSync(filePath, { enableForeignKeyConstraints: false });
|
||
} catch (_err) {
|
||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||
const Database = require("better-sqlite3") as any;
|
||
return new Database(filePath);
|
||
}
|
||
};
|
||
|
||
const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
|
||
const dir = createTempDir();
|
||
const filePath = path.join(dir, "legacy-export.db");
|
||
const db = openWritableDb(filePath);
|
||
|
||
const tableDrawing = opts.tableStyle === "plural-lower" ? "drawings" : "Drawing";
|
||
const tableCollection = opts.tableStyle === "plural-lower" ? "collections" : "Collection";
|
||
|
||
try {
|
||
if (opts.includeCollections) {
|
||
db.exec(`
|
||
CREATE TABLE "${tableCollection}" (
|
||
id TEXT PRIMARY KEY NOT NULL,
|
||
name TEXT NOT NULL,
|
||
createdAt TEXT,
|
||
updatedAt TEXT
|
||
);
|
||
`);
|
||
db.prepare(`INSERT INTO "${tableCollection}" (id, name, createdAt, updatedAt) VALUES (?, ?, ?, ?)`).run(
|
||
"legacy-collection-1",
|
||
"Legacy Collection",
|
||
new Date("2024-01-01T00:00:00.000Z").toISOString(),
|
||
new Date("2024-01-02T00:00:00.000Z").toISOString(),
|
||
);
|
||
}
|
||
|
||
db.exec(`
|
||
CREATE TABLE "${tableDrawing}" (
|
||
id TEXT PRIMARY KEY NOT NULL,
|
||
name TEXT NOT NULL,
|
||
elements TEXT NOT NULL,
|
||
appState TEXT NOT NULL,
|
||
files TEXT,
|
||
preview TEXT,
|
||
version INTEGER,
|
||
collectionId TEXT,
|
||
collectionName TEXT,
|
||
createdAt TEXT,
|
||
updatedAt TEXT
|
||
);
|
||
`);
|
||
|
||
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
|
||
const insertDrawing = db.prepare(
|
||
`INSERT INTO "${tableDrawing}"
|
||
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
|
||
VALUES
|
||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
);
|
||
|
||
insertDrawing.run(
|
||
"legacy-drawing-1",
|
||
"Legacy Drawing 1",
|
||
JSON.stringify([]),
|
||
JSON.stringify({}),
|
||
JSON.stringify({}),
|
||
null,
|
||
1,
|
||
opts.includeCollections ? "legacy-collection-1" : null,
|
||
opts.includeCollections ? "Legacy Collection" : null,
|
||
now,
|
||
now,
|
||
);
|
||
|
||
insertDrawing.run(
|
||
"legacy-drawing-2",
|
||
"Legacy Drawing 2 (unorganized)",
|
||
JSON.stringify([]),
|
||
JSON.stringify({}),
|
||
JSON.stringify({}),
|
||
null,
|
||
2,
|
||
null,
|
||
null,
|
||
now,
|
||
now,
|
||
);
|
||
|
||
if (opts.includeTrashDrawing) {
|
||
insertDrawing.run(
|
||
"legacy-drawing-trash",
|
||
"Legacy Trash Drawing",
|
||
JSON.stringify([]),
|
||
JSON.stringify({}),
|
||
JSON.stringify({}),
|
||
null,
|
||
1,
|
||
"trash",
|
||
"Trash",
|
||
now,
|
||
now,
|
||
);
|
||
}
|
||
|
||
if (opts.includeMigrationsTable) {
|
||
db.exec(`
|
||
CREATE TABLE "_prisma_migrations" (
|
||
id TEXT PRIMARY KEY NOT NULL,
|
||
checksum TEXT NOT NULL,
|
||
finished_at TEXT,
|
||
migration_name TEXT NOT NULL,
|
||
logs TEXT,
|
||
rolled_back_at TEXT,
|
||
started_at TEXT NOT NULL,
|
||
applied_steps_count INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
`);
|
||
db.prepare(
|
||
`INSERT INTO "_prisma_migrations"
|
||
(id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
|
||
VALUES
|
||
(?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).run(
|
||
"m1",
|
||
"checksum",
|
||
new Date("2024-01-04T00:00:00.000Z").toISOString(),
|
||
"20240104000000_initial",
|
||
null,
|
||
null,
|
||
new Date("2024-01-04T00:00:00.000Z").toISOString(),
|
||
1,
|
||
);
|
||
}
|
||
} finally {
|
||
db.close();
|
||
}
|
||
|
||
return filePath;
|
||
};
|
||
|
||
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
|
||
const dir = createTempDir();
|
||
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
|
||
const zip = new JSZip();
|
||
|
||
const manifest = {
|
||
format: "excalidash",
|
||
formatVersion: 1,
|
||
exportedAt: new Date().toISOString(),
|
||
unorganizedFolder: "Unorganized",
|
||
collections: [] as any[],
|
||
drawings: [
|
||
{
|
||
id: "duplicate-drawing-id",
|
||
name: "Drawing One",
|
||
filePath: "Unorganized/drawing-1.excalidraw",
|
||
collectionId: null,
|
||
},
|
||
{
|
||
id: "duplicate-drawing-id",
|
||
name: "Drawing Two",
|
||
filePath: "Unorganized/drawing-2.excalidraw",
|
||
collectionId: null,
|
||
},
|
||
],
|
||
};
|
||
|
||
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
|
||
zip.file(
|
||
"Unorganized/drawing-1.excalidraw",
|
||
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||
);
|
||
zip.file(
|
||
"Unorganized/drawing-2.excalidraw",
|
||
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||
);
|
||
|
||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||
fs.writeFileSync(filePath, buffer);
|
||
return filePath;
|
||
};
|
||
|
||
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
|
||
const dir = createTempDir();
|
||
const filePath = path.join(dir, "legacy-duplicate-ids.db");
|
||
const db = openWritableDb(filePath);
|
||
|
||
try {
|
||
db.exec(`
|
||
CREATE TABLE "Drawing" (
|
||
id TEXT,
|
||
name TEXT NOT NULL,
|
||
elements TEXT NOT NULL,
|
||
appState TEXT NOT NULL,
|
||
files TEXT,
|
||
preview TEXT,
|
||
version INTEGER,
|
||
collectionId TEXT,
|
||
collectionName TEXT,
|
||
createdAt TEXT,
|
||
updatedAt TEXT
|
||
);
|
||
`);
|
||
|
||
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
|
||
const insertDrawing = db.prepare(
|
||
`INSERT INTO "Drawing"
|
||
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
);
|
||
|
||
insertDrawing.run(
|
||
"legacy-duplicate-id",
|
||
"Legacy Drawing A",
|
||
JSON.stringify([]),
|
||
JSON.stringify({}),
|
||
JSON.stringify({}),
|
||
null,
|
||
1,
|
||
null,
|
||
null,
|
||
now,
|
||
now,
|
||
);
|
||
|
||
insertDrawing.run(
|
||
"legacy-duplicate-id",
|
||
"Legacy Drawing B",
|
||
JSON.stringify([]),
|
||
JSON.stringify({}),
|
||
JSON.stringify({}),
|
||
null,
|
||
1,
|
||
null,
|
||
null,
|
||
now,
|
||
now,
|
||
);
|
||
} finally {
|
||
db.close();
|
||
}
|
||
|
||
return filePath;
|
||
};
|
||
|
||
describe("Import compatibility (legacy exports)", () => {
|
||
const uploadsDir = path.resolve(__dirname, "../../uploads");
|
||
const userAgent = "vitest-import-compat";
|
||
let prisma: ReturnType<typeof getTestPrisma>;
|
||
let app: any;
|
||
let agent: any;
|
||
let csrfHeaderName: string;
|
||
let csrfToken: string;
|
||
|
||
beforeAll(async () => {
|
||
setupTestDb();
|
||
prisma = getTestPrisma();
|
||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||
|
||
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
|
||
({ app } = await import("../index"));
|
||
|
||
agent = request.agent(app);
|
||
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
|
||
csrfHeaderName = csrfRes.body.header;
|
||
csrfToken = csrfRes.body.token;
|
||
expect(typeof csrfHeaderName).toBe("string");
|
||
expect(typeof csrfToken).toBe("string");
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await cleanupTestDb(prisma);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await prisma.$disconnect();
|
||
});
|
||
|
||
it("verifies a v0.1.x–v0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => {
|
||
const legacyDb = createLegacySqliteDb({
|
||
tableStyle: "prisma",
|
||
includeCollections: true,
|
||
includeMigrationsTable: true,
|
||
includeTrashDrawing: false,
|
||
});
|
||
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy/verify")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.valid).toBe(true);
|
||
expect(res.body.drawings).toBe(2);
|
||
expect(res.body.collections).toBe(1);
|
||
expect(res.body.latestMigration).toBe("20240104000000_initial");
|
||
expect(typeof res.body.currentLatestMigration === "string").toBe(true);
|
||
});
|
||
|
||
it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => {
|
||
const legacyDb = createLegacySqliteDb({
|
||
tableStyle: "prisma",
|
||
includeCollections: true,
|
||
includeMigrationsTable: false,
|
||
includeTrashDrawing: true,
|
||
});
|
||
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
expect(res.body.collections?.created).toBeGreaterThanOrEqual(1);
|
||
expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3);
|
||
|
||
const importedDrawings = await prisma.drawing.findMany({
|
||
orderBy: { name: "asc" },
|
||
select: { id: true, name: true, collectionId: true, userId: true },
|
||
});
|
||
|
||
// In single-user mode, imports land on the bootstrap acting user.
|
||
expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true);
|
||
expect(importedDrawings.map((d) => d.id)).toEqual(
|
||
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
|
||
);
|
||
|
||
const trash = await prisma.collection.findUnique({
|
||
where: { id: "trash:bootstrap-admin" },
|
||
});
|
||
expect(trash).toBeTruthy();
|
||
});
|
||
|
||
it("supports older exports with plural/lowercase table names (drawings/collections)", async () => {
|
||
const legacyDb = createLegacySqliteDb({
|
||
tableStyle: "plural-lower",
|
||
includeCollections: true,
|
||
includeMigrationsTable: false,
|
||
includeTrashDrawing: false,
|
||
});
|
||
|
||
const verify = await agent
|
||
.post("/import/sqlite/legacy/verify")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(verify.status).toBe(200);
|
||
expect(verify.body.drawings).toBe(2);
|
||
expect(verify.body.collections).toBe(1);
|
||
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
});
|
||
|
||
it("fails verification if the legacy DB is missing a Drawing table", async () => {
|
||
const dir = createTempDir();
|
||
const filePath = path.join(dir, "invalid.db");
|
||
const db = openWritableDb(filePath);
|
||
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
|
||
db.close();
|
||
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy/verify")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", filePath);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe("Invalid legacy DB");
|
||
});
|
||
|
||
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
|
||
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||
const res = await agent
|
||
.post("/import/excalidash/verify")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("archive", archive);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||
});
|
||
|
||
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
|
||
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||
const res = await agent
|
||
.post("/import/excalidash")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("archive", archive);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||
});
|
||
|
||
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
|
||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy/verify")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||
});
|
||
|
||
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
|
||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||
const res = await agent
|
||
.post("/import/sqlite/legacy")
|
||
.set("User-Agent", userAgent)
|
||
.set(csrfHeaderName, csrfToken)
|
||
.attach("db", legacyDb);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||
});
|
||
});
|