diff --git a/tests/domain/vault/write-paths.test.ts b/tests/domain/vault/write-paths.test.ts index 9cf25e89..3827f9a2 100644 --- a/tests/domain/vault/write-paths.test.ts +++ b/tests/domain/vault/write-paths.test.ts @@ -1,28 +1,173 @@ import { describe, expect, it, vi } from "vitest"; import { + ensureVaultFolder, sanitizeVaultPathSegment, uniqueVaultPath, type VaultPathDestination, vaultRelativeFolderPath, + withVaultWriteLock, } from "../../../src/domain/vault/write-paths"; +import { deferred, waitForAsyncWork } from "../../support/async"; describe("vault write paths", () => { + it("runs writes for the same destination in FIFO order", async () => { + const destination = lockDestination(); + const firstRelease = deferred(); + const secondRelease = deferred(); + const events: string[] = []; + + const first = withVaultWriteLock(destination, async () => { + events.push("first:start"); + await firstRelease.promise; + events.push("first:end"); + }); + const second = withVaultWriteLock(destination, async () => { + events.push("second:start"); + await secondRelease.promise; + events.push("second:end"); + }); + + await waitForAsyncWork(() => expect(events).toEqual(["first:start"])); + firstRelease.resolve(); + await first; + await waitForAsyncWork(() => expect(events).toEqual(["first:start", "first:end", "second:start"])); + const third = withVaultWriteLock(destination, async () => { + events.push("third"); + }); + await Promise.resolve(); + expect(events).toEqual(["first:start", "first:end", "second:start"]); + + secondRelease.resolve(); + await Promise.all([second, third]); + + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end", "third"]); + }); + + it("releases the write lock when an operation fails", async () => { + const destination = lockDestination(); + const firstRelease = deferred(); + const first = withVaultWriteLock(destination, async () => { + await firstRelease.promise; + throw new Error("write failed"); + }); + const firstFailure = expect(first).rejects.toThrow("write failed"); + const second = withVaultWriteLock(destination, async () => "second completed"); + + firstRelease.resolve(); + + await firstFailure; + await expect(second).resolves.toBe("second completed"); + }); + + it("shares serialization across destinations with the same write lock key", async () => { + const writeLockKey = {}; + const firstDestination = lockDestination(writeLockKey); + const secondDestination = lockDestination(writeLockKey); + const firstRelease = deferred(); + const events: string[] = []; + + const first = withVaultWriteLock(firstDestination, async () => { + events.push("first"); + await firstRelease.promise; + }); + const second = withVaultWriteLock(secondDestination, async () => { + events.push("second"); + }); + + await waitForAsyncWork(() => expect(events).toEqual(["first"])); + firstRelease.resolve(); + await Promise.all([first, second]); + + expect(events).toEqual(["first", "second"]); + }); + + it("allows destinations with different write lock keys to write concurrently", async () => { + const firstRelease = deferred(); + const secondRelease = deferred(); + const events: string[] = []; + + const first = withVaultWriteLock(lockDestination({}), async () => { + events.push("first"); + await firstRelease.promise; + }); + const second = withVaultWriteLock(lockDestination({}), async () => { + events.push("second"); + await secondRelease.promise; + }); + + await waitForAsyncWork(() => expect(events).toEqual(["first", "second"])); + firstRelease.resolve(); + secondRelease.resolve(); + await Promise.all([first, second]); + }); + it("sanitizes Obsidian path and subpath marker characters", () => { expect(sanitizeVaultPathSegment("Topic/[draft]#section^block?")).toBe("Topic--draft--section-block-"); }); - it("validates vault-relative folders before sanitizing relative segments", () => { + it.each([ + "/outside", + String.raw`C:\outside`, + String.raw`z:\outside`, + String.raw`\\server\share`, + ])("rejects absolute vault folder path %s", (path) => { + expect(() => vaultRelativeFolderPath(path, folderPathOptions())).toThrow("absolute"); + }); + + it.each(["../outside", "./outside"])("validates vault-relative folder %s before sanitizing its segments", (path) => { + expect(() => vaultRelativeFolderPath(path, folderPathOptions())).toThrow("relative"); + }); + + it("uses and normalizes the configured fallback for empty or sanitized-empty folders", () => { + const options = folderPathOptions("Fallback\\Notes"); + + expect(vaultRelativeFolderPath(" ", options)).toBe("Fallback/Notes"); + expect(vaultRelativeFolderPath("...", options)).toBe("Fallback/Notes"); + }); + + it("rejects an empty folder when no fallback is configured", () => { + expect(() => vaultRelativeFolderPath(" ", folderPathOptions())).toThrow("empty"); + }); + + it("normalizes whitespace and empty sanitized segments in a relative folder", () => { + expect(vaultRelativeFolderPath(" Parent // ... / Child ", folderPathOptions())).toBe("Parent/Child"); + expect(vaultRelativeFolderPath("Notes/C:/outside", folderPathOptions())).toBe("Notes/C-/outside"); + }); + + it("rejects relative traversal introduced by path normalization", () => { expect(() => - vaultRelativeFolderPath("../outside", { - normalizePath: (path) => path, - emptyPathMessage: "empty", - absolutePathMessage: "absolute", - relativeSegmentMessage: "relative", + vaultRelativeFolderPath("safe", { + ...folderPathOptions(), + normalizePath: () => "../outside", }), ).toThrow("relative"); }); + it("creates missing folder segments in parent-first order and skips existing segments", async () => { + const events: string[] = []; + const destination: VaultPathDestination = { + normalizePath: (path) => path, + exists: vi.fn(async (path) => { + events.push(`exists:${path}`); + return path === "Archive"; + }), + createFolder: vi.fn(async (path) => { + events.push(`create:${path}`); + }), + }; + + await ensureVaultFolder(destination, "Archive/2026/July"); + + expect(events).toEqual([ + "exists:Archive", + "exists:Archive/2026", + "create:Archive/2026", + "exists:Archive/2026/July", + "create:Archive/2026/July", + ]); + }); + it("starts collision suffixes at 2 for generated vault paths", async () => { const destination = memoryDestination(["Files/Paper.pdf"]); @@ -36,6 +181,25 @@ describe("vault write paths", () => { }); }); +function lockDestination(writeLockKey?: object): VaultPathDestination { + return { + ...(writeLockKey === undefined ? {} : { writeLockKey }), + normalizePath: (path) => path, + exists: vi.fn(async () => false), + createFolder: vi.fn(async () => undefined), + }; +} + +function folderPathOptions(emptyFallback?: string) { + return { + normalizePath: (path: string) => path.replaceAll("\\", "/"), + emptyPathMessage: "empty", + absolutePathMessage: "absolute", + relativeSegmentMessage: "relative", + ...(emptyFallback === undefined ? {} : { emptyFallback }), + }; +} + function memoryDestination(existingPaths: readonly string[]): Pick { const paths = new Set(existingPaths); return { diff --git a/tests/features/chat/domain/runtime/resolution.test.ts b/tests/features/chat/domain/runtime/resolution.test.ts index b63bc9c1..9bdce58a 100644 --- a/tests/features/chat/domain/runtime/resolution.test.ts +++ b/tests/features/chat/domain/runtime/resolution.test.ts @@ -89,6 +89,82 @@ describe("runtime control resolution", () => { }); }); + it("marks the sandbox policy pending and unknown when selecting a different permission profile", () => { + const runtimeConfig = runtimeConfigFixture({}); + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: { + ...runtimeConfig, + startupPermissions: { + approvalPolicy: "on-request", + activePermissionProfile: { id: ":startup", extends: null }, + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: false, + }, + }, + }, + active: { + sandboxPolicyKnown: true, + permissionProfileKnown: true, + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: { id: ":read-only", extends: null }, + }, + pending: { permissionProfile: setRuntimeIntentValue(":workspace") }, + }); + + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "pending" }, + sandboxPolicy: { + confirmed: { type: "readOnly", networkAccess: false }, + confirmedSource: "active-thread", + effective: null, + source: "pending", + }, + }); + }); + + it("uses the configured sandbox policy when reselecting the startup permission profile", () => { + const configuredPolicy = { + type: "workspaceWrite" as const, + writableRoots: ["/vault"], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: false, + }; + const runtimeConfig = runtimeConfigFixture({}); + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: { + ...runtimeConfig, + startupPermissions: { + approvalPolicy: "on-request", + activePermissionProfile: { id: ":workspace", extends: null }, + sandboxPolicy: configuredPolicy, + }, + }, + active: { + sandboxPolicyKnown: true, + permissionProfileKnown: true, + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: { id: ":read-only", extends: null }, + }, + pending: { permissionProfile: setRuntimeIntentValue(":workspace") }, + }); + const config = snapshotConfig(snapshot); + const resolution = resolveRuntimeControls(snapshot, config); + + expect(resolution.sandboxPolicy).toMatchObject({ + confirmed: { type: "readOnly", networkAccess: false }, + confirmedSource: "active-thread", + effective: configuredPolicy, + source: "pending", + }); + }); + it("resolves auto-review mode from requested, active, then effective config", () => { const requested = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("user") },