Skip to content

Commit 01fda32

Browse files
committed
test(import): add legacy import compatibility coverage
1 parent 94694de commit 01fda32

5 files changed

Lines changed: 520 additions & 35 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
2+
import request from "supertest";
3+
import fs from "fs";
4+
import path from "path";
5+
import os from "os";
6+
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
7+
8+
type LegacyDbOptions = {
9+
tableStyle: "prisma" | "plural-lower";
10+
includeCollections: boolean;
11+
includeMigrationsTable: boolean;
12+
includeTrashDrawing: boolean;
13+
};
14+
15+
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "excalidash-legacy-"));
16+
17+
const openWritableDb = (filePath: string): any => {
18+
try {
19+
// eslint-disable-next-line @typescript-eslint/no-var-requires
20+
const { DatabaseSync } = require("node:sqlite") as any;
21+
return new DatabaseSync(filePath, { enableForeignKeyConstraints: false });
22+
} catch (_err) {
23+
// eslint-disable-next-line @typescript-eslint/no-var-requires
24+
const Database = require("better-sqlite3") as any;
25+
return new Database(filePath);
26+
}
27+
};
28+
29+
const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
30+
const dir = createTempDir();
31+
const filePath = path.join(dir, "legacy-export.db");
32+
const db = openWritableDb(filePath);
33+
34+
const tableDrawing = opts.tableStyle === "plural-lower" ? "drawings" : "Drawing";
35+
const tableCollection = opts.tableStyle === "plural-lower" ? "collections" : "Collection";
36+
37+
try {
38+
if (opts.includeCollections) {
39+
db.exec(`
40+
CREATE TABLE "${tableCollection}" (
41+
id TEXT PRIMARY KEY NOT NULL,
42+
name TEXT NOT NULL,
43+
createdAt TEXT,
44+
updatedAt TEXT
45+
);
46+
`);
47+
db.prepare(`INSERT INTO "${tableCollection}" (id, name, createdAt, updatedAt) VALUES (?, ?, ?, ?)`).run(
48+
"legacy-collection-1",
49+
"Legacy Collection",
50+
new Date("2024-01-01T00:00:00.000Z").toISOString(),
51+
new Date("2024-01-02T00:00:00.000Z").toISOString(),
52+
);
53+
}
54+
55+
db.exec(`
56+
CREATE TABLE "${tableDrawing}" (
57+
id TEXT PRIMARY KEY NOT NULL,
58+
name TEXT NOT NULL,
59+
elements TEXT NOT NULL,
60+
appState TEXT NOT NULL,
61+
files TEXT,
62+
preview TEXT,
63+
version INTEGER,
64+
collectionId TEXT,
65+
collectionName TEXT,
66+
createdAt TEXT,
67+
updatedAt TEXT
68+
);
69+
`);
70+
71+
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
72+
const insertDrawing = db.prepare(
73+
`INSERT INTO "${tableDrawing}"
74+
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
75+
VALUES
76+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
77+
);
78+
79+
insertDrawing.run(
80+
"legacy-drawing-1",
81+
"Legacy Drawing 1",
82+
JSON.stringify([]),
83+
JSON.stringify({}),
84+
JSON.stringify({}),
85+
null,
86+
1,
87+
opts.includeCollections ? "legacy-collection-1" : null,
88+
opts.includeCollections ? "Legacy Collection" : null,
89+
now,
90+
now,
91+
);
92+
93+
insertDrawing.run(
94+
"legacy-drawing-2",
95+
"Legacy Drawing 2 (unorganized)",
96+
JSON.stringify([]),
97+
JSON.stringify({}),
98+
JSON.stringify({}),
99+
null,
100+
2,
101+
null,
102+
null,
103+
now,
104+
now,
105+
);
106+
107+
if (opts.includeTrashDrawing) {
108+
insertDrawing.run(
109+
"legacy-drawing-trash",
110+
"Legacy Trash Drawing",
111+
JSON.stringify([]),
112+
JSON.stringify({}),
113+
JSON.stringify({}),
114+
null,
115+
1,
116+
"trash",
117+
"Trash",
118+
now,
119+
now,
120+
);
121+
}
122+
123+
if (opts.includeMigrationsTable) {
124+
db.exec(`
125+
CREATE TABLE "_prisma_migrations" (
126+
id TEXT PRIMARY KEY NOT NULL,
127+
checksum TEXT NOT NULL,
128+
finished_at TEXT,
129+
migration_name TEXT NOT NULL,
130+
logs TEXT,
131+
rolled_back_at TEXT,
132+
started_at TEXT NOT NULL,
133+
applied_steps_count INTEGER NOT NULL DEFAULT 0
134+
);
135+
`);
136+
db.prepare(
137+
`INSERT INTO "_prisma_migrations"
138+
(id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
139+
VALUES
140+
(?, ?, ?, ?, ?, ?, ?, ?)`
141+
).run(
142+
"m1",
143+
"checksum",
144+
new Date("2024-01-04T00:00:00.000Z").toISOString(),
145+
"20240104000000_initial",
146+
null,
147+
null,
148+
new Date("2024-01-04T00:00:00.000Z").toISOString(),
149+
1,
150+
);
151+
}
152+
} finally {
153+
db.close();
154+
}
155+
156+
return filePath;
157+
};
158+
159+
describe("Import compatibility (legacy exports)", () => {
160+
const uploadsDir = path.resolve(__dirname, "../../uploads");
161+
const userAgent = "vitest-import-compat";
162+
let prisma: ReturnType<typeof getTestPrisma>;
163+
let app: any;
164+
let csrfHeaderName: string;
165+
let csrfToken: string;
166+
167+
beforeAll(async () => {
168+
setupTestDb();
169+
prisma = getTestPrisma();
170+
fs.mkdirSync(uploadsDir, { recursive: true });
171+
172+
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
173+
({ app } = await import("../index"));
174+
175+
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
176+
csrfHeaderName = csrfRes.body.header;
177+
csrfToken = csrfRes.body.token;
178+
expect(typeof csrfHeaderName).toBe("string");
179+
expect(typeof csrfToken).toBe("string");
180+
});
181+
182+
beforeEach(async () => {
183+
await cleanupTestDb(prisma);
184+
});
185+
186+
afterAll(async () => {
187+
await prisma.$disconnect();
188+
});
189+
190+
it("verifies a v0.1.x–v0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => {
191+
const legacyDb = createLegacySqliteDb({
192+
tableStyle: "prisma",
193+
includeCollections: true,
194+
includeMigrationsTable: true,
195+
includeTrashDrawing: false,
196+
});
197+
198+
const res = await request(app)
199+
.post("/import/sqlite/legacy/verify")
200+
.set("User-Agent", userAgent)
201+
.set(csrfHeaderName, csrfToken)
202+
.attach("db", legacyDb);
203+
204+
expect(res.status).toBe(200);
205+
expect(res.body.valid).toBe(true);
206+
expect(res.body.drawings).toBe(2);
207+
expect(res.body.collections).toBe(1);
208+
expect(res.body.latestMigration).toBe("20240104000000_initial");
209+
expect(typeof res.body.currentLatestMigration === "string").toBe(true);
210+
});
211+
212+
it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => {
213+
const legacyDb = createLegacySqliteDb({
214+
tableStyle: "prisma",
215+
includeCollections: true,
216+
includeMigrationsTable: false,
217+
includeTrashDrawing: true,
218+
});
219+
220+
const res = await request(app)
221+
.post("/import/sqlite/legacy")
222+
.set("User-Agent", userAgent)
223+
.set(csrfHeaderName, csrfToken)
224+
.attach("db", legacyDb);
225+
226+
expect(res.status).toBe(200);
227+
expect(res.body.success).toBe(true);
228+
expect(res.body.collections?.created).toBeGreaterThanOrEqual(1);
229+
expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3);
230+
231+
const importedDrawings = await prisma.drawing.findMany({
232+
orderBy: { name: "asc" },
233+
select: { id: true, name: true, collectionId: true, userId: true },
234+
});
235+
236+
// In single-user mode, imports land on the bootstrap acting user.
237+
expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true);
238+
expect(importedDrawings.map((d) => d.id)).toEqual(
239+
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
240+
);
241+
242+
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
243+
expect(trash).toBeTruthy();
244+
});
245+
246+
it("supports older exports with plural/lowercase table names (drawings/collections)", async () => {
247+
const legacyDb = createLegacySqliteDb({
248+
tableStyle: "plural-lower",
249+
includeCollections: true,
250+
includeMigrationsTable: false,
251+
includeTrashDrawing: false,
252+
});
253+
254+
const verify = await request(app)
255+
.post("/import/sqlite/legacy/verify")
256+
.set("User-Agent", userAgent)
257+
.set(csrfHeaderName, csrfToken)
258+
.attach("db", legacyDb);
259+
260+
expect(verify.status).toBe(200);
261+
expect(verify.body.drawings).toBe(2);
262+
expect(verify.body.collections).toBe(1);
263+
264+
const res = await request(app)
265+
.post("/import/sqlite/legacy")
266+
.set("User-Agent", userAgent)
267+
.set(csrfHeaderName, csrfToken)
268+
.attach("db", legacyDb);
269+
270+
expect(res.status).toBe(200);
271+
expect(res.body.success).toBe(true);
272+
});
273+
274+
it("fails verification if the legacy DB is missing a Drawing table", async () => {
275+
const dir = createTempDir();
276+
const filePath = path.join(dir, "invalid.db");
277+
const db = openWritableDb(filePath);
278+
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
279+
db.close();
280+
281+
const res = await request(app)
282+
.post("/import/sqlite/legacy/verify")
283+
.set("User-Agent", userAgent)
284+
.set(csrfHeaderName, csrfToken)
285+
.attach("db", filePath);
286+
287+
expect(res.status).toBe(400);
288+
expect(res.body.error).toBe("Invalid legacy DB");
289+
});
290+
});

backend/src/auth.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => {
121121
},
122122
standardHeaders: true,
123123
legacyHeaders: false,
124+
validate: {
125+
trustProxy: false,
126+
},
124127
store,
125128
keyGenerator: (req) => {
126129
const identifier = resolveAuthIdentifier(req as Request);
@@ -165,6 +168,9 @@ const accountActionRateLimiter = rateLimit({
165168
},
166169
standardHeaders: true,
167170
legacyHeaders: false,
171+
validate: {
172+
trustProxy: false,
173+
},
168174
});
169175

170176
// Validation schemas

0 commit comments

Comments
 (0)