test: harden vault and runtime boundaries

This commit is contained in:
murashit 2026-07-18 18:02:16 +09:00
parent ef5d1d803d
commit 8351156589
2 changed files with 246 additions and 6 deletions

View file

@ -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<void>();
const secondRelease = deferred<void>();
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<void>();
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<void>();
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<void>();
const secondRelease = deferred<void>();
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<VaultPathDestination, "normalizePath" | "exists"> {
const paths = new Set(existingPaths);
return {

View file

@ -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") },