369 lines
15 KiB
JavaScript
369 lines
15 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import Database from "better-sqlite3";
|
|
|
|
const root = path.join(process.cwd(), ".data", `phase8-import-smoke-${Date.now()}`);
|
|
const dataDir = path.join(root, "data");
|
|
const exportDir = path.join(root, "export");
|
|
const databasePath = path.join(dataDir, "neta.db");
|
|
const ownerUserId = "phase8-target-owner";
|
|
const sourceOwnerUserId = "00000000-0000-4000-8000-000000000008";
|
|
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
|
const rollbackDir = path.join(root, "rollback");
|
|
|
|
fs.mkdirSync(path.join(exportDir, "storage", "avatars"), { recursive: true });
|
|
fs.mkdirSync(path.join(exportDir, "storage", "project-assets"), { recursive: true });
|
|
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
|
cwd: process.cwd(),
|
|
env,
|
|
stdio: "inherit",
|
|
});
|
|
|
|
const db = new Database(databasePath);
|
|
db.pragma("foreign_keys = ON");
|
|
db.prepare(`
|
|
insert into user (id, name, email, email_verified, created_at, updated_at)
|
|
values (?, ?, ?, 1, ?, ?)
|
|
`).run(ownerUserId, "Target Owner", "phase8-owner@example.com", Date.now(), Date.now());
|
|
db.prepare(`
|
|
insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at)
|
|
values (?, ?, ?, 'freelancer', 0, ?, ?)
|
|
`).run(ownerUserId, "phase8-owner@example.com", "Target Owner", Date.now(), Date.now());
|
|
db.close();
|
|
|
|
execFileSync(process.execPath, ["scripts/backup.mjs"], {
|
|
cwd: process.cwd(),
|
|
env,
|
|
stdio: "inherit",
|
|
});
|
|
const preCutoverBackupDir = fs
|
|
.readdirSync(path.join(dataDir, "backups"))
|
|
.map((name) => path.join(dataDir, "backups", name))
|
|
.sort()
|
|
.at(-1);
|
|
assert.ok(preCutoverBackupDir, "Pre-cutover backup must exist");
|
|
|
|
const avatarBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
|
const coverBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5, 6, 7, 8]);
|
|
const avatarLocalPath = "storage/avatars/avatar.png";
|
|
const coverLocalPath = "storage/project-assets/cover.png";
|
|
fs.writeFileSync(path.join(exportDir, avatarLocalPath), avatarBytes);
|
|
fs.writeFileSync(path.join(exportDir, coverLocalPath), coverBytes);
|
|
|
|
const baseBundle = createBundle();
|
|
writeBundle(exportDir, baseBundle);
|
|
|
|
runImport(["--dry-run"]);
|
|
let check = new Database(databasePath);
|
|
assert.equal(check.prepare("select count(*) as value from clients").get().value, 0, "Dry-run must not mutate");
|
|
check.close();
|
|
|
|
runImport([]);
|
|
runImport(["--allow-existing"]);
|
|
|
|
check = new Database(databasePath);
|
|
check.pragma("foreign_keys = ON");
|
|
try {
|
|
assert.equal(check.prepare("select count(*) as value from clients").get().value, 1);
|
|
assert.equal(check.prepare("select count(*) as value from projects").get().value, 1);
|
|
assert.equal(check.prepare("select count(*) as value from files").get().value, 2);
|
|
assert.equal(check.prepare("select count(*) as value from journal_entries").get().value, 1);
|
|
assert.equal(
|
|
check.prepare("select status from tasks where id = 'source-task'").get().status,
|
|
"done",
|
|
"Legacy completed task status must normalize to done",
|
|
);
|
|
assert.equal(
|
|
check.prepare("select source_journal_entry_id as value from tasks where id = 'source-task'").get().value,
|
|
"source-daily",
|
|
"Merged journal references must target the canonical daily log",
|
|
);
|
|
assert.equal(
|
|
check.prepare("select amount_minor as value from finance_transactions where id = 'source-finance'").get().value,
|
|
1234,
|
|
"Money must convert to integer minor units exactly",
|
|
);
|
|
assert.equal(
|
|
check.prepare("select tax_basis_points as value from invoices where id = 'source-invoice'").get().value,
|
|
1850,
|
|
"Tax percentage must convert to basis points exactly",
|
|
);
|
|
const journal = check.prepare(`
|
|
select note, legacy_ai_metadata as legacyAiMetadata
|
|
from journal_entries where id = 'source-daily'
|
|
`).get();
|
|
assert.match(journal.note, /Daily note/);
|
|
assert.match(journal.note, /Legacy journal content/);
|
|
assert.match(journal.legacyAiMetadata, /source-journal/);
|
|
const settings = check.prepare(`
|
|
select p.timezone, p.default_currency as defaultCurrency,
|
|
a.provider, a.model, a.encrypted_api_key as encryptedApiKey
|
|
from user_preferences p
|
|
inner join user_ai_settings a on a.owner_user_id = p.owner_user_id
|
|
where p.owner_user_id = ?
|
|
`).get(ownerUserId);
|
|
assert.deepEqual(settings, {
|
|
timezone: "Europe/Istanbul",
|
|
defaultCurrency: "TRY",
|
|
provider: "gemini",
|
|
model: "gemini-test-model",
|
|
encryptedApiKey: null,
|
|
});
|
|
assert.equal(
|
|
check.prepare("select auth_user_id as value from clients where id = 'source-client'").get().value,
|
|
null,
|
|
"Legacy client auth links must not be imported",
|
|
);
|
|
assert.equal(
|
|
check.prepare("select requested_by_user_id as value from project_revisions where id = 'source-revision'").get().value,
|
|
ownerUserId,
|
|
"Historical revision requester must use a valid target principal",
|
|
);
|
|
const project = check.prepare(`
|
|
select legacy_cover_image_path as cover from projects where id = 'source-project'
|
|
`).get();
|
|
assert.match(project.cover, /^\/api\/files\/import-/);
|
|
const section = check.prepare(`
|
|
select metadata from project_planning_sections where id = 'source-section'
|
|
`).get();
|
|
assert.match(section.metadata, /\/api\/files\/import-/);
|
|
assert.equal(check.prepare("select name from user where id = ?").get(ownerUserId).name, "Source Owner");
|
|
assert.deepEqual(check.pragma("foreign_key_check"), [], "Imported database must satisfy all foreign keys");
|
|
} finally {
|
|
check.close();
|
|
}
|
|
|
|
const reportText = fs.readFileSync(path.join(dataDir, "import-report.json"), "utf8");
|
|
assert.doesNotMatch(reportText, /legacy-plain-text-secret|source-password-secret/);
|
|
assert.match(reportText, /intentionally not imported/);
|
|
|
|
const invalidEnumDir = cloneExport("invalid-enum");
|
|
const invalidEnum = structuredClone(baseBundle);
|
|
invalidEnum.tables.tasks[0].status = "mystery";
|
|
writeBundle(invalidEnumDir, invalidEnum);
|
|
assertImportFails(invalidEnumDir, /unknown value/);
|
|
|
|
const invalidForeignKeyDir = cloneExport("invalid-foreign-key");
|
|
const invalidForeignKey = structuredClone(baseBundle);
|
|
invalidForeignKey.tables.projects[0].client_id = "missing-client";
|
|
writeBundle(invalidForeignKeyDir, invalidForeignKey);
|
|
assertImportFails(invalidForeignKeyDir, /references missing id/);
|
|
|
|
const unsafeStorageDir = cloneExport("unsafe-storage");
|
|
const unsafeStorage = structuredClone(baseBundle);
|
|
unsafeStorage.storage.objects[0].local_path = "../escape.png";
|
|
writeBundle(unsafeStorageDir, unsafeStorage);
|
|
assertImportFails(unsafeStorageDir, /unsafe|escapes root/);
|
|
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
"scripts/restore.mjs",
|
|
"--from",
|
|
preCutoverBackupDir,
|
|
"--target",
|
|
rollbackDir,
|
|
"--force",
|
|
],
|
|
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
|
|
);
|
|
const rollbackDb = new Database(path.join(rollbackDir, "neta.db"), { readonly: true });
|
|
try {
|
|
assert.equal(
|
|
rollbackDb.prepare("select count(*) as value from clients").get().value,
|
|
0,
|
|
"Rollback backup must restore the pre-import client count",
|
|
);
|
|
assert.equal(
|
|
rollbackDb.prepare("select count(*) as value from user where id = ?").get(ownerUserId).value,
|
|
1,
|
|
"Rollback backup must preserve the Better Auth owner",
|
|
);
|
|
} finally {
|
|
rollbackDb.close();
|
|
}
|
|
|
|
console.log("Phase 8 import smoke passed: dry-run, normalization, files, idempotency, negative validation and rollback rehearsal verified.");
|
|
|
|
function runImport(extraArgs, from = exportDir) {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
"scripts/import-supabase.mjs",
|
|
"--from",
|
|
from,
|
|
"--owner-user-id",
|
|
ownerUserId,
|
|
...extraArgs,
|
|
],
|
|
{ cwd: process.cwd(), env, stdio: "inherit" },
|
|
);
|
|
}
|
|
|
|
function assertImportFails(from, pattern) {
|
|
assert.throws(
|
|
() => execFileSync(
|
|
process.execPath,
|
|
[
|
|
"scripts/import-supabase.mjs",
|
|
"--from",
|
|
from,
|
|
"--owner-user-id",
|
|
ownerUserId,
|
|
"--dry-run",
|
|
],
|
|
{ cwd: process.cwd(), env, stdio: "pipe" },
|
|
),
|
|
(error) => pattern.test(`${error.stdout ?? ""}\n${error.stderr ?? ""}`),
|
|
);
|
|
}
|
|
|
|
function cloneExport(name) {
|
|
const destination = path.join(root, name);
|
|
fs.cpSync(exportDir, destination, { recursive: true });
|
|
return destination;
|
|
}
|
|
|
|
function writeBundle(directory, bundle) {
|
|
fs.writeFileSync(path.join(directory, "export.json"), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
}
|
|
|
|
function hash(bytes) {
|
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function createBundle() {
|
|
const createdAt = "2026-07-17T10:00:00.000Z";
|
|
const tableNames = [
|
|
"profiles", "clients", "client_activities", "projects", "project_planning_sections",
|
|
"project_revisions", "tasks", "calendar_events", "finance_transactions", "daily_logs",
|
|
"journals", "chat_sessions", "chat_messages", "proposals", "contracts", "invoices",
|
|
"subscriptions", "app_settings", "document_embeddings",
|
|
];
|
|
const tables = Object.fromEntries(tableNames.map((name) => [name, []]));
|
|
|
|
tables.profiles.push({
|
|
id: sourceOwnerUserId,
|
|
first_name: "Source",
|
|
last_name: "Owner",
|
|
avatar_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
|
|
});
|
|
tables.clients.push({
|
|
id: "source-client", user_id: sourceOwnerUserId, client_auth_id: "legacy-client-auth-user",
|
|
name: "Imported Client", email: "client@example.com", status: "active",
|
|
pipeline_stage: "won", created_at: createdAt,
|
|
});
|
|
tables.client_activities.push({
|
|
id: "source-activity", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
type: "meeting", title: "Imported Meeting", activity_date: createdAt, created_at: createdAt,
|
|
});
|
|
tables.projects.push({
|
|
id: "source-project", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
name: "Imported Project", type: "client_project", status: "active",
|
|
budget_amount: "1000.05", currency: "try", progress: 25, progress_type: "manual",
|
|
revision_quota: 2, cover_image_path: "source/project-cover.png", created_at: createdAt,
|
|
});
|
|
tables.daily_logs.push({
|
|
id: "source-daily", user_id: sourceOwnerUserId, log_date: "2026-07-17",
|
|
mood_score: 4, energy_score: 3, work_satisfaction_score: 5,
|
|
note: "Daily note", created_at: createdAt,
|
|
});
|
|
tables.journals.push({
|
|
id: "source-journal", user_id: sourceOwnerUserId, date: "2026-07-17T08:00:00.000Z",
|
|
mood: "focused", energy: 4, content: "Legacy journal content", ai_tags: ["focus"],
|
|
ai_summary: "Legacy summary", created_at: createdAt,
|
|
});
|
|
tables.tasks.push({
|
|
id: "source-task", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
project_id: "source-project", source_journal_id: "source-journal", title: "Imported Task",
|
|
status: "completed", priority: "high", date: "2026-07-17",
|
|
is_public_to_client: true, created_at: createdAt,
|
|
});
|
|
tables.calendar_events.push({
|
|
id: "source-event", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
project_id: "source-project", task_id: "source-task", title: "Imported Event",
|
|
type: "meeting", starts_at: createdAt, ends_at: "2026-07-17T11:00:00.000Z",
|
|
created_at: createdAt,
|
|
});
|
|
tables.finance_transactions.push({
|
|
id: "source-finance", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
project_id: "source-project", type: "income", amount: "12.34", currency: "try",
|
|
transaction_date: "2026-07-17", payment_status: "paid", created_at: createdAt,
|
|
});
|
|
tables.project_planning_sections.push({
|
|
id: "source-section", user_id: sourceOwnerUserId, project_id: "source-project",
|
|
category: "assets", title: "Imported Assets", metadata: { cover: "source/project-cover.png" },
|
|
sort_order: 0, created_at: createdAt,
|
|
});
|
|
tables.project_revisions.push({
|
|
id: "source-revision", project_id: "source-project", client_id: "source-client",
|
|
requested_by: "legacy-client-auth-user", description: "Imported revision",
|
|
status: "completed", created_at: createdAt,
|
|
});
|
|
tables.chat_sessions.push({
|
|
id: "source-chat", user_id: sourceOwnerUserId, title: "Imported Chat", created_at: createdAt,
|
|
});
|
|
tables.chat_messages.push({
|
|
id: "source-message", session_id: "source-chat", role: "user",
|
|
content: "Imported message", context_journal_ids: ["source-journal"], created_at: createdAt,
|
|
});
|
|
tables.proposals.push({
|
|
id: "source-proposal", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
project_id: "source-project", title: "Imported Proposal", amount: "100.00",
|
|
currency: "TRY", status: "accepted", created_at: createdAt,
|
|
});
|
|
tables.contracts.push({
|
|
id: "source-contract", user_id: sourceOwnerUserId, proposal_id: "source-proposal",
|
|
client_id: "source-client", title: "Imported Contract", status: "active", created_at: createdAt,
|
|
});
|
|
tables.invoices.push({
|
|
id: "source-invoice", user_id: sourceOwnerUserId, client_id: "source-client",
|
|
project_id: "source-project", invoice_number: "IMPORT-001", amount: "100.00",
|
|
tax_rate: "18.50", currency: "TRY", status: "paid", issue_date: "2026-07-17",
|
|
created_at: createdAt,
|
|
});
|
|
tables.subscriptions.push({
|
|
id: "source-subscription", user_id: sourceOwnerUserId, name: "Imported Hosting",
|
|
amount: "50.00", currency: "TRY", billing_cycle: "monthly", status: "active",
|
|
created_at: createdAt,
|
|
});
|
|
tables.app_settings.push({
|
|
id: "source-settings", user_id: sourceOwnerUserId, timezone: "Europe/Istanbul",
|
|
currency: "TRY", ai_provider: "google", ai_model: "gemini-test-model",
|
|
api_key: "legacy-plain-text-secret", created_at: createdAt,
|
|
});
|
|
tables.document_embeddings.push({
|
|
id: "source-embedding", user_id: sourceOwnerUserId, content: "Archived only",
|
|
});
|
|
|
|
return {
|
|
format: "neta-supabase-export",
|
|
version: 1,
|
|
exported_at: createdAt,
|
|
source: { owner_user_id: sourceOwnerUserId },
|
|
auth: { users: [{ password: "source-password-secret" }] },
|
|
tables,
|
|
storage: {
|
|
objects: [
|
|
{
|
|
bucket: "avatars", object_path: "source/avatar.png",
|
|
source_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
|
|
local_path: avatarLocalPath, original_name: "avatar.png", mime_type: "image/png",
|
|
bytes: avatarBytes.length, sha256: hash(avatarBytes), created_at: createdAt,
|
|
},
|
|
{
|
|
bucket: "project-assets", object_path: "source/project-cover.png",
|
|
local_path: coverLocalPath, original_name: "cover.png", mime_type: "image/png",
|
|
bytes: coverBytes.length, sha256: hash(coverBytes), project_id: "source-project",
|
|
portal_visible: true,
|
|
references: [{ type: "project_cover", project_id: "source-project" }],
|
|
created_at: createdAt,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|