* fix(boards): add permissions to board summary schema to fix manage page crash The getAllBoards query fetches userPermissions and groupPermissions from the DB, but boardSummarySchema (the output validator) did not include them. tRPC stripped the fields, causing a TypeError when getBoardPermissionsAsync called .some() on undefined. * fix(backup): cast Buffer to Uint8Array for NextResponse body Node 24 narrowed the BodyInit type, making Buffer incompatible. Wrapping in Uint8Array satisfies the type constraint. * fix(lint): remove forbidden non-null assertions across backup and board code Replace non-null assertions with safe guards, optional chaining, and for-of iteration so oxlint passes in CI. Also remove unused Collapse import.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import crypto from "crypto";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
|
|
import AdmZip from "adm-zip";
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
|
|
|
const ALGORITHM = "aes-256-cbc";
|
|
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
|
|
|
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
|
|
|
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
|
const key = Buffer.from(keyHex, "hex");
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
let encrypted = cipher.update(text);
|
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
|
};
|
|
|
|
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
|
const key = Buffer.from(keyHex, "hex");
|
|
const [data, dataIv] = value.split(".") as [string, string];
|
|
const iv = Buffer.from(dataIv, "hex");
|
|
const encrypted = Buffer.from(data, "hex");
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
let decrypted = decipher.update(encrypted);
|
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
return decrypted.toString();
|
|
};
|
|
|
|
const createTestDb = (dbPath: string) => {
|
|
const sqlite = new Database(dbPath);
|
|
const db = drizzle(sqlite, { casing: DB_CASING });
|
|
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
return { db, sqlite };
|
|
};
|
|
|
|
describe("SQLite backup", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("export ZIP structure", () => {
|
|
it("should create a ZIP containing all required entries", () => {
|
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
const { sqlite } = createTestDb(dbPath);
|
|
|
|
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
sqlite.close();
|
|
|
|
const dbBuffer = fs.readFileSync(backupPath);
|
|
const metadata = {
|
|
homarrVersion: "test",
|
|
exportedAt: new Date().toISOString(),
|
|
dbDialect: "sqlite",
|
|
encryptionKey: generateHexKey(),
|
|
};
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("db.sqlite", dbBuffer);
|
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
|
|
|
const zipPath = path.join(tmpDir, "backup.zip");
|
|
zip.writeZip(zipPath);
|
|
|
|
const readBack = new AdmZip(zipPath);
|
|
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
|
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
|
|
|
const dbEntry = readBack.getEntry("db.sqlite");
|
|
expect(dbEntry).toBeDefined();
|
|
expect(dbEntry?.header.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should produce a valid SQLite database in the ZIP", () => {
|
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
const { sqlite } = createTestDb(dbPath);
|
|
|
|
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
sqlite.close();
|
|
|
|
const backupDb = new Database(backupPath, { readonly: true });
|
|
const tables = backupDb
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
.all() as { name: string }[];
|
|
|
|
expect(tables.map((t) => t.name)).toContain("board");
|
|
expect(tables.map((t) => t.name)).toContain("user");
|
|
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
|
|
|
backupDb.close();
|
|
});
|
|
});
|
|
|
|
describe("import ZIP validation", () => {
|
|
it("should detect missing required entries", () => {
|
|
const zip = new AdmZip();
|
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
const required = ["db.sqlite", "metadata.json"];
|
|
const missing = required.filter((name) => !entries.includes(name));
|
|
|
|
expect(missing).toEqual(["metadata.json"]);
|
|
});
|
|
|
|
it("should accept valid backup ZIP with all entries", () => {
|
|
const zip = new AdmZip();
|
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
|
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
const required = ["db.sqlite", "metadata.json"];
|
|
const missing = required.filter((name) => !entries.includes(name));
|
|
|
|
expect(missing).toHaveLength(0);
|
|
});
|
|
|
|
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
|
const zip = new AdmZip();
|
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
|
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
const required = ["db.sqlite", "metadata.json"];
|
|
const missing = required.filter((name) => !entries.includes(name));
|
|
|
|
expect(missing).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("secret re-encryption", () => {
|
|
it("should re-encrypt secrets from old key to new key", () => {
|
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
const { sqlite } = createTestDb(dbPath);
|
|
|
|
const oldKey = generateHexKey();
|
|
const newKey = generateHexKey();
|
|
const plaintext = "my-secret-password";
|
|
|
|
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
|
|
|
sqlite.exec(`
|
|
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
`);
|
|
sqlite.exec(`
|
|
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
`);
|
|
|
|
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
|
expect(decryptedBefore).toBe(plaintext);
|
|
|
|
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
|
const newKeyBuf = Buffer.from(newKey, "hex");
|
|
|
|
const rows = sqlite
|
|
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
|
.all() as { integration_id: string; kind: string; value: string }[];
|
|
|
|
const updateStmt = sqlite.prepare(
|
|
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const [data, dataIv] = row.value.split(".") as [string, string];
|
|
const iv = Buffer.from(dataIv, "hex");
|
|
const encrypted = Buffer.from(data, "hex");
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
|
|
|
const newIv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
|
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
|
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
|
|
|
updateStmt.run(newValue, row.integration_id, row.kind);
|
|
}
|
|
|
|
const updatedRow = sqlite
|
|
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
.get("int-1") as { value: string };
|
|
|
|
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
|
expect(decryptedAfter).toBe(plaintext);
|
|
|
|
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
|
|
|
sqlite.close();
|
|
});
|
|
|
|
it("should skip re-encryption when keys match", () => {
|
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
const { sqlite } = createTestDb(dbPath);
|
|
|
|
const sameKey = generateHexKey();
|
|
const plaintext = "my-secret";
|
|
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
|
|
|
sqlite.exec(`
|
|
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
`);
|
|
sqlite.exec(`
|
|
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
`);
|
|
|
|
const rowBefore = sqlite
|
|
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
.get("int-1") as { value: string };
|
|
|
|
expect(rowBefore.value).toBe(encryptedValue);
|
|
|
|
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
|
expect(decrypted).toBe(plaintext);
|
|
|
|
sqlite.close();
|
|
});
|
|
});
|
|
|
|
describe("VACUUM INTO consistency", () => {
|
|
it("should produce a consistent copy via VACUUM INTO", () => {
|
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
const { sqlite } = createTestDb(dbPath);
|
|
|
|
sqlite.exec(`
|
|
INSERT INTO "app" ("id", "name", "icon_url")
|
|
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
|
`);
|
|
|
|
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
|
|
const backupDb = new Database(backupPath, { readonly: true });
|
|
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
|
|
|
expect(app).toBeDefined();
|
|
expect(app?.name).toBe("TestApp");
|
|
|
|
backupDb.close();
|
|
sqlite.close();
|
|
});
|
|
});
|
|
|
|
describe("migration on imported DB", () => {
|
|
it("should successfully migrate an imported database", () => {
|
|
const sourcePath = path.join(tmpDir, "source.sqlite");
|
|
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
|
|
|
sourceDb.exec(`
|
|
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
|
|
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
|
`);
|
|
sourceDb.close();
|
|
|
|
const importPath = path.join(tmpDir, "import.sqlite");
|
|
fs.copyFileSync(sourcePath, importPath);
|
|
|
|
const importDb = new Database(importPath);
|
|
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
|
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
|
|
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
|
expect(board).toBeDefined();
|
|
expect(board?.name).toBe("TestBoard");
|
|
|
|
importDb.close();
|
|
});
|
|
});
|
|
|
|
describe("encryption key validation", () => {
|
|
it("should reject invalid hex keys", () => {
|
|
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
|
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
|
|
for (const key of invalidKeys) {
|
|
expect(hexKeyRegex.test(key)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("should accept valid 64-char hex keys", () => {
|
|
const validKey = generateHexKey();
|
|
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
expect(hexKeyRegex.test(validKey)).toBe(true);
|
|
});
|
|
});
|
|
});
|