import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ensureDir, writeTextAtomic } from "../src/lib/fs"; import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard"; import { persistInstallState } from "../src/lib/installStateWriter"; import { buildInstallStateRecord } from "../src/lib/installState"; /** * install-state.json — это метаданные восстановления, и записывать их нужно так, * чтобы после отказа на сервере лежал ЛИБО прежний полный документ, ЛИБО новый * полный документ. * * Что было. install писал маркер через `writeText` — обычную перезапись на * месте. Она укорачивает файл до нуля и только потом наполняет, поэтому отказ * между этими моментами оставлял половину JSON. reconfigure писал тот же файл * атомарно, то есть у одного файла было две разные гарантии в зависимости от * того, какая команда его обновляла. * * Проверяется здесь именно наблюдаемое поведение: временный файл не выживает, * целевой никогда не остаётся обрезанным, права выставлены до подстановки. * Сам fsync проверить из пользовательского процесса нельзя — его наличие * закрепляет приёмка по исходнику. */ const onPosix = process.platform !== "win32"; let dir = ""; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "hy2xs-atomic-")); }); afterEach(async () => { disableReadOnlyGuard(); await rm(dir, { recursive: true, force: true }); }); async function leftovers(): Promise { return (await readdir(dir)).filter((name) => name.includes(".tmp-")); } describe("writeTextAtomic", () => { test("создаёт файл с объявленным содержимым", async () => { const target = join(dir, "install-state.json"); await writeTextAtomic(target, '{"product":"hy2xs"}\n', { mode: 0o644 }); expect(await readFile(target, "utf8")).toBe('{"product":"hy2xs"}\n'); }); test("не оставляет временных файлов после успешной записи", async () => { await writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 }); expect(await leftovers()).toEqual([]); }); test("заменяет прежнее содержимое целиком, а не дописывает", async () => { const target = join(dir, "state.json"); await writeFile(target, "старое длинное содержимое, которое должно исчезнуть целиком"); await writeTextAtomic(target, "{}\n", { mode: 0o644 }); expect(await readFile(target, "utf8")).toBe("{}\n"); }); // Ключевое отличие от перезаписи на месте: пока новая запись не удалась, // по целевому пути лежит ПРЕЖНИЙ полный документ, а не его обрезок. test("при отказе записи прежний файл остаётся нетронутым", async () => { const target = join(dir, "state.json"); const previous = '{"product":"hy2xs","phase":"installed"}\n'; await writeFile(target, previous); // Каталог назначения исчезает между вызовами: создать временный файл негде, // то есть запись падает на самом раннем шаге. const missing = join(dir, "gone", "state.json"); await expect(writeTextAtomic(missing, "{}\n", { mode: 0o644 })).rejects.toThrow(); expect(await readFile(target, "utf8")).toBe(previous); }); test("отказ подстановки не оставляет временный файл", async () => { // Цель — каталог: rename файла поверх непустого каталога не проходит. const target = join(dir, "state.json"); await mkdir(target); await mkdir(join(target, "occupied")); await expect(writeTextAtomic(target, "{}\n", { mode: 0o644 })).rejects.toThrow(); expect(await leftovers()).toEqual([]); }); test.skipIf(!onPosix)("права выставляются точно, независимо от umask", async () => { const target = join(dir, "state.json"); await writeTextAtomic(target, "{}\n", { mode: 0o600 }); expect(((await stat(target)).mode & 0o777).toString(8)).toBe("600"); }); test.skipIf(!onPosix)("режим 0644 маркера установки сохраняется", async () => { const target = join(dir, "install-state.json"); await writeTextAtomic(target, "{}\n", { mode: 0o644 }); expect(((await stat(target)).mode & 0o777).toString(8)).toBe("644"); }); test("запись проходит через read-only guard", async () => { enableReadOnlyGuard("test phase"); await expect(writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 })).rejects.toThrow( /read-only guard violation/ ); }); }); describe("ensureDir", () => { test("создаёт недостающие уровни", async () => { const target = join(dir, "var", "lib", "hy2xs"); await ensureDir(target, { mode: 0o755 }); expect((await stat(target)).isDirectory()).toBe(true); }); test("повторный вызов не является ошибкой", async () => { const target = join(dir, "hy2xs"); await ensureDir(target, { mode: 0o755 }); await ensureDir(target, { mode: 0o755 }); expect((await stat(target)).isDirectory()).toBe(true); }); // mkdir не меняет права уже существующего каталога, поэтому «создать» и // «права такие, как объявлено» — два разных действия. test.skipIf(!onPosix)("права приводятся к объявленным у существующего каталога", async () => { const target = join(dir, "hy2xs"); await mkdir(target, { mode: 0o700 }); await ensureDir(target, { mode: 0o755 }); expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755"); }); test("создание каталога проходит через read-only guard", async () => { enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow( /read-only guard violation.*PHASE 0/s ); }); }); describe("persistInstallState", () => { // Единственный писатель маркера обязан идти через guarded-примитивы: иначе // PHASE 0 смогла бы создать /var/lib/hy2xs, и «read-only» перестало бы быть // правдой ровно для того файла, по которому clean-host принимает решение. test("под read-only guard запись маркера невозможна", async () => { enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); const record = buildInstallStateRecord({ productVersion: "1.0.0", buildId: "b", opId: "op", startedAt: "2026-08-30T00:00:00.000Z", phase: "preflight_ok", installed: false, ownedPaths: [] }); await expect(persistInstallState(record)).rejects.toThrow(/read-only guard violation/); }); });