mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: keep foreign keys on during user data import (#1626)
Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path.
This commit is contained in:
1 parent
872e5eba73
commit
3941dd4ecc
6 files changed
+368
-321
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import Database from "better-sqlite3";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
new URL("../../database/database.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"database.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/database/import"',
|
||||
) as ts.ExpressionStatement;
|
||||
const batched = (route.expression as ts.CallExpression)
|
||||
.arguments[3] as ts.CallExpression;
|
||||
const callback = batched.arguments[0];
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "termix-import-route-"));
|
||||
|
||||
function exportFile(): string {
|
||||
const path = join(dir, `export-${Math.random()}.db`);
|
||||
const db = new Database(path);
|
||||
db.exec(`
|
||||
CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT);
|
||||
CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER);
|
||||
INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin');
|
||||
INSERT INTO ssh_data VALUES
|
||||
(1, '10.0.0.1', 22, 'root', 7),
|
||||
(2, '10.0.0.2', 22, 'root', 8),
|
||||
(3, '10.0.0.3', 22, 'root', 99),
|
||||
(4, '10.0.0.4', 22, 'root', NULL);
|
||||
`);
|
||||
db.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||
const writes: string[] = [];
|
||||
const hosts: Array<Record<string, unknown>> = [];
|
||||
const importHostProtocolLogins = vi.fn();
|
||||
const handler = runInNewContext(
|
||||
ts.transpileModule(`(${callback.getText(ast)})`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
fs,
|
||||
Buffer,
|
||||
Database,
|
||||
parseUserAgent: () => ({ type: "web" }),
|
||||
createCurrentUserRepository: () => ({ findById: async () => user }),
|
||||
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
|
||||
apiLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findIdForImportIdentity: async (_: string, name: string) =>
|
||||
name === "kept" ? 42 : null,
|
||||
createEncryptedForUser: async () => {
|
||||
writes.push("credential");
|
||||
return { id: 100 };
|
||||
},
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
existsForImportIdentity: async () => false,
|
||||
createEncryptedForUser: async (_: string, host: never) => {
|
||||
writes.push("host");
|
||||
hosts.push(host);
|
||||
return { id: 500 + hosts.length };
|
||||
},
|
||||
}),
|
||||
legacyStatusCheck: () => ({}),
|
||||
sshOptionsForWrite: () => null,
|
||||
applyHostDefaultsToWrite: async () => ({}),
|
||||
applyPluginHostImportSettings: async () => {},
|
||||
importedHostPluginSettings: () => ({}),
|
||||
importHostProtocolLogins,
|
||||
applyDefaultsAfterHostWrites: async () => {},
|
||||
importUserPluginRows: async () => ({
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
}),
|
||||
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||
getErrorMessage: (error: Error) => error.message,
|
||||
},
|
||||
);
|
||||
const res = { json: vi.fn(), status: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
await handler(
|
||||
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||
res,
|
||||
);
|
||||
return { res, writes, hosts, importHostProtocolLogins };
|
||||
}
|
||||
|
||||
it("points imported hosts at the credentials imported with them", async () => {
|
||||
const { res, writes, hosts, importHostProtocolLogins } =
|
||||
await invoke(exportFile());
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual(["credential", "host", "host", "host", "host"]);
|
||||
expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]);
|
||||
expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([
|
||||
[7, 100],
|
||||
[8, 42],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes the uploaded file when the user is gone", async () => {
|
||||
const path = exportFile();
|
||||
const { res } = await invoke(path, null);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the uploaded file when it is not a SQLite database", async () => {
|
||||
const path = join(dir, "not-sqlite.db");
|
||||
writeFileSync(path, "definitely not a database");
|
||||
const { res } = await invoke(path);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the uploaded file after a successful import", async () => {
|
||||
const path = exportFile();
|
||||
await invoke(path);
|
||||
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
Reference in new issue
Block a user