Files
homarr/tools/mysql-to-sqlite/test/fixture.mjs
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

267 lines
9.5 KiB
JavaScript

import { execFile } from "node:child_process";
import { createCipheriv, randomBytes } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { setTimeout } from "node:timers/promises";
import mysql from "mysql2/promise";
import { quote, schema } from "../src/legacy.mjs";
const exec = promisify(execFile);
// Published v1.77.1 multi-platform image. Its bundled migrator, not our frozen
// SQL helper, must produce the source schema and journal used by these tests.
const sourceImage =
"ghcr.io/homarr-labs/homarr:v1.77.1@sha256:1f5b892aeef4ad0a4907f075777bbd0ce7120f4cdc53b9f3f028519250421aed";
export const fixtureUser = "migration-user";
export const fixturePassword = "migration-password";
export const fixtureBoard = "migration-board";
export const fixtureSecret = "migration-secret";
export const fixtureMedia = Buffer.from([0, 1, 2, 127, 128, 255]);
export const fixtureDate = "2025-03-14 15:09:26";
export const fixturePasswordHash = "$2b$10$RPaXzPcun3JX2n8.A138S.m.AVTueysEPV77vJf/pKGI5DdCzVW0y";
const iv = Buffer.alloc(16, 1);
const cipher = createCipheriv("aes-256-cbc", Buffer.alloc(32), iv);
export const fixtureCiphertext = `${Buffer.concat([cipher.update(fixtureSecret), cipher.final()]).toString("hex")}.${iv.toString("hex")}`;
const ids = {
user: fixtureUser,
board: fixtureBoard,
group: "migration-group",
app: "migration-app",
custom_widget_definition: "migration-custom",
iconRepository: "migration-repository",
icon: "migration-icon",
integration: "migration-integration",
item: "migration-item",
layout: "migration-layout",
media: "migration-media",
section: "migration-section",
search_engine: "migration-search",
apiKey: "migration-api-key",
invite: "migration-invite",
};
const references = {
user_id: ids.user,
creator_id: ids.user,
owner_id: ids.user,
board_id: ids.board,
home_board_id: ids.board,
group_id: ids.group,
app_id: ids.app,
definition_id: ids.custom_widget_definition,
icon_repository_id: ids.iconRepository,
integration_id: ids.integration,
item_id: ids.item,
layout_id: ids.layout,
section_id: ids.section,
};
const overrides = {
user: {
name: fixtureUser,
email: "migration@example.test",
email_verified: fixtureDate,
password: fixturePasswordHash,
provider: "credentials",
home_board_id: ids.board,
color_scheme: "dark",
first_day_of_week: 1,
completed_manage_tour: 1,
completed_board_tour: 1,
},
group: { name: "credentials-admin", position: 0 },
groupPermission: { permission: "admin" },
boardGroupPermission: { permission: "full" },
boardUserPermission: { permission: "full" },
integrationGroupPermissions: { permission: "full" },
integrationUserPermission: { permission: "full" },
board: {
name: "converted-board",
page_title: "Converted board",
primary_color: "blue",
secondary_color: "violet",
background_image_attachment: "fixed",
background_image_repeat: "no-repeat",
background_image_size: "cover",
item_radius: "md",
opacity: 100,
is_public: 0,
custom_css: "/* Unicode: café 🏡 */\n" + "x".repeat(60_000),
},
onboarding: { id: "onboarding", step: "finish", previous_step: "setup" },
integration: { name: "Converted Pi-hole", kind: "piHole", url: "http://127.0.0.1:9" },
integrationSecret: { kind: "apiKey", value: fixtureCiphertext },
custom_widget_definition: {
name: "Legacy weather",
url: "https://example.test/weather",
enabled: 0,
auth_type: "bearer",
method: "GET",
display_type: "singleValue",
display_config: '{"type":"singleValue","jsonPath":"$.temperature"}',
},
custom_widget_secret: { kind: "apiKey", value: fixtureCiphertext },
widget_secret: { kind: "apiKey", value: fixtureCiphertext },
app: { name: "Converted app", icon_url: "/favicon.ico", href: "https://example.test" },
iconRepository: { slug: "migration/repository" },
icon: { name: "fixture", url: "https://example.test/icon.png", checksum: "fixture" },
item: { kind: "clock", options: '{"json":{}}', advanced_options: '{"json":{}}' },
layout: { name: "Base", column_count: 12, breakpoint: 768 },
section: { kind: "empty", x_offset: 0, y_offset: 0, options: '{"json":{}}' },
section_layout: { parent_section_id: null, x_offset: 0, y_offset: 0, width: 12, height: 3 },
item_layout: { x_offset: 0, y_offset: 0, width: 3, height: 2 },
media: {
name: "Binary fixture",
content: fixtureMedia,
content_type: "application/octet-stream",
size: fixtureMedia.length,
},
search_engine: {
name: "Fixture search",
short: "fx",
type: "generic",
icon_url: "/favicon.ico",
url_template: "https://example.test/?q={query}",
},
cron_job_configuration: { name: "ping", cron_expression: "* * * * *", is_enabled: 0 },
serverSetting: { setting_key: "migration-fixture", value: '{"json":{"unicode":"café 🏡"}}' },
account: { type: "oauth", provider: "oidc", provider_account_id: "migration-external-user" },
apiKey: { api_key: fixturePasswordHash },
trusted_certificate_hostname: { hostname: "example.test", thumbprint: "fixture", certificate: "fixture certificate" },
};
export async function createFixture() {
const password = randomBytes(16).toString("hex");
const { stdout } = await exec("docker", [
"run",
"-d",
"--rm",
"-p",
"127.0.0.1::3306",
"-e",
`MYSQL_ROOT_PASSWORD=${password}`,
"-e",
"MYSQL_DATABASE=homarr",
"mysql:8.4.6",
]);
const container = stdout.trim();
let connection;
const cleanup = async () => {
try {
await connection?.end();
} finally {
await exec("docker", ["rm", "-f", container]);
}
};
try {
const { stdout: published } = await exec("docker", ["port", container, "3306/tcp"]);
const port = Number(published.trim().split(":").at(-1));
const connectionOptions = {
host: "127.0.0.1",
port,
user: "root",
password,
database: "homarr",
timezone: "Z",
dateStrings: true,
};
const deadline = Date.now() + 120_000;
while (!connection) {
try {
connection = await mysql.createConnection(connectionOptions);
} catch (error) {
if (Date.now() >= deadline) throw error;
await setTimeout(250);
}
}
await connection.query("SET SESSION time_zone = '+00:00'");
const environmentDirectory = await mkdtemp(join(tmpdir(), "homarr-release-source-"));
try {
const environmentFile = join(environmentDirectory, "source.env");
await writeFile(
environmentFile,
[
"DB_DRIVER=mysql2",
"DB_DIALECT=mysql",
"DB_HOST=127.0.0.1",
`DB_PORT=${port}`,
"DB_USER=root",
`DB_PASSWORD=${password}`,
"DB_NAME=homarr",
"SKIP_ENV_VALIDATION=true",
"NO_EXTERNAL_CONNECTION=true",
`SECRET_ENCRYPTION_KEY=${"0".repeat(64)}`,
].join("\n") + "\n",
{ mode: 0o600 },
);
await exec(
"docker",
[
"run",
"--rm",
"--network",
"host",
"--env-file",
environmentFile,
"--entrypoint",
"node",
sourceImage,
"/app/db/migrations/mysql/migrate.cjs",
"/app/db/migrations/mysql",
],
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
} finally {
await rm(environmentDirectory, { recursive: true, force: true });
}
await connection.query("SET FOREIGN_KEY_CHECKS = 0");
// Replace release seed rows with deterministic user data, leaving the real
// release's schema and migration bookkeeping intact.
const [tables] = await connection.query("SHOW TABLES");
for (const table of tables) {
const name = Object.values(table)[0];
if (name !== "__drizzle_migrations") await connection.query(`DELETE FROM ${quote(name)}`);
}
for (const table of schema.tables) {
const values = Object.fromEntries(
table.columns.map((column) => {
let value = !column.notNull
? null
: column.encoding === "boolean"
? 0
: column.encoding === "integer"
? 1
: ["seconds", "milliseconds"].includes(column.encoding)
? fixtureDate
: "fixture";
if (column.name === "id") value = ids[table.name] ?? `migration-${table.name}`;
if (references[column.name] && column.notNull) value = references[column.name];
return [column.name, value];
}),
);
Object.assign(values, overrides[table.name]);
await connection.query(
`INSERT INTO ${quote(table.name)} (${Object.keys(values).map(quote).join(", ")}) VALUES (${Object.keys(values)
.map(() => "?")
.join(", ")})`,
Object.values(values),
);
}
// More than one copy batch, including duplicate non-key values and Unicode.
for (let i = 0; i < 251; i++)
await connection.query("INSERT INTO serverSetting (setting_key, value) VALUES (?, ?)", [
`migration-${i}`,
"café 🏡",
]);
await connection.query("SET FOREIGN_KEY_CHECKS = 1");
await connection.query("CREATE USER 'migration_reader'@'%' IDENTIFIED BY ?", [password]);
await connection.query("GRANT SELECT ON homarr.* TO 'migration_reader'@'%'");
return { connectionOptions: { ...connectionOptions, user: "migration_reader" }, connection, container, cleanup };
} catch (error) {
await cleanup();
throw error;
}
}