Files
Termix/src/backend/tests/plugins/user-data.test.ts
T

198 lines
6.4 KiB
TypeScript

/**
* A user's rows in plugin tables travel with their export and come back on
* import, found through the table definitions plugins registered rather than
* any table name core knows.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
defineTable,
encryptedText,
id,
refHost,
refUser,
text,
varchar,
} from "@termix-ssh/plugin-sdk/db";
let sqlite: Database.Database;
vi.mock("../../database/db/index.js", () => ({
getDb: () => drizzle(sqlite),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const { registerTable, resetPluginData } =
await import("../../plugins/data.js");
const {
importUserPluginRows,
listUserOwnedTables,
readUserPluginRows,
writeUserPluginTables,
} = await import("../../plugins/user-data.js");
const { createTableSql } = await import("../../plugins/table-builder.js");
// Stands for an adopted table; adoptLegacyTable only accepts real core names.
const bookmarks = {
...defineTable("bookmark", {
id: id(),
userId: refUser(),
path: varchar(200).notNull(),
note: text(),
token: encryptedText(),
}),
adopts: "bookmarks_legacy",
};
const shared = defineTable("shared_thing", { id: id(), label: text() });
function createTables(target: Database.Database) {
target.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
target.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
for (const definition of [bookmarks, shared]) {
for (const statement of createTableSql("sqlite", "fixture", definition)) {
target.exec(statement);
}
}
}
beforeEach(() => {
sqlite = new Database(":memory:");
createTables(sqlite);
sqlite.exec("INSERT INTO users (id) VALUES ('u1'), ('u2')");
sqlite.exec(`INSERT INTO p_fixture_bookmark (user_id, path, note, token)
VALUES ('u1', '/etc', 'config', 'sealed'), ('u2', '/var', null, null)`);
registerTable("fixture", bookmarks);
registerTable("fixture", shared);
});
afterEach(() => {
resetPluginData();
sqlite.close();
});
describe("plugin user data", () => {
it("lists only tables with a user column, without keys or secrets", () => {
const [owned] = listUserOwnedTables();
expect(listUserOwnedTables()).toHaveLength(1);
expect(owned).toMatchObject({
pluginId: "fixture",
table: "p_fixture_bookmark",
legacyName: "bookmarks_legacy",
userColumn: "user_id",
columns: ["user_id", "path", "note"],
});
});
it("reads one user's rows", async () => {
expect(await readUserPluginRows("u1")).toEqual({
p_fixture_bookmark: [{ user_id: "u1", path: "/etc", note: "config" }],
});
});
it("round-trips through an export file into another user", async () => {
// The export file has the user row, as database.ts writes it first.
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
expect(await writeUserPluginTables(file, "u1")).toBe(1);
sqlite.exec("DELETE FROM p_fixture_bookmark");
const result = await importUserPluginRows(file, "u2");
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
expect(
sqlite.prepare("SELECT user_id, path FROM p_fixture_bookmark").all(),
).toEqual([{ user_id: "u2", path: "/etc" }]);
const again = await importUserPluginRows(file, "u2");
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
);
sqlite.transaction(() => {
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
})();
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
const transaction = vi.spyOn(file, "transaction");
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const written = await writeUserPluginTables(file, "u1");
clearInterval(ticker);
expect(written).toBe(5000);
expect(transaction).toHaveBeenCalled();
expect(ticks).toBeGreaterThan(0);
expect(
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
).toEqual({ n: 5000 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
"CREATE TABLE bookmarks_legacy (id INTEGER PRIMARY KEY, user_id TEXT, path TEXT, note TEXT)",
);
file.exec(
"INSERT INTO bookmarks_legacy (user_id, path, note) VALUES ('old', '/srv', 'x')",
);
const result = await importUserPluginRows(file, "u1");
expect(result.imported).toBe(1);
expect(
sqlite
.prepare(
"SELECT user_id, note FROM p_fixture_bookmark WHERE path = '/srv'",
)
.get(),
).toEqual({ user_id: "u1", note: "x" });
});
it("skips imported rows that point at another user or host", async () => {
sqlite.exec("DROP TABLE ssh_data");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
sqlite.exec(
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
);
const grants = defineTable("grant", {
id: id(),
ownerId: refUser(),
granteeId: refUser(),
hostId: refHost(),
});
for (const statement of createTableSql("sqlite", "fixture", grants)) {
sqlite.exec(statement);
}
registerTable("fixture", grants);
const file = new Database(":memory:");
file.exec(
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
);
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
const result = await importUserPluginRows(file, "u2");
expect(result).toMatchObject({ imported: 1, skipped: 2 });
expect(
sqlite
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
.all(),
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
});
});