Archived
test: audit existing suite
This commit is contained in:
@@ -35,15 +35,32 @@ describe("config routes", () => {
|
||||
});
|
||||
|
||||
it("updates config through the service", async () => {
|
||||
const requestedConfig: PiWebConfigValues = {
|
||||
host: "0.0.0.0",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
spawnSessions: true,
|
||||
subsessions: true,
|
||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||
plugins: { info: { enabled: false, settings: { note: "hidden" } } },
|
||||
pathAccess: { allowedPaths: ["/tmp"] },
|
||||
uploads: { defaultFolder: "uploads\\manual" },
|
||||
maxUploadBytes: 1234,
|
||||
};
|
||||
const expectedConfig: PiWebConfigValues = {
|
||||
...requestedConfig,
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
||||
payload: { config: requestedConfig },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
expect(savedConfig).toEqual(expectedConfig);
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(expectedConfig);
|
||||
});
|
||||
|
||||
it("rejects invalid config payloads before writing", async () => {
|
||||
@@ -109,11 +126,16 @@ describe("config routes", () => {
|
||||
|
||||
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
const selectedMachinePatch: PiWebConfigValues = {
|
||||
plugins: { info: { enabled: false } },
|
||||
uploads: { defaultFolder: "uploads\\manual" },
|
||||
spawnSessions: true,
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
|
||||
payload: { config: selectedMachinePatch },
|
||||
});
|
||||
|
||||
const expectedConfig: PiWebConfigValues = {
|
||||
|
||||
@@ -536,7 +536,7 @@ async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>)
|
||||
function cleanProcessEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) {
|
||||
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || key.startsWith("PI_WEB_")) {
|
||||
Reflect.deleteProperty(env, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("MachineService", () => {
|
||||
expect(await service.list()).toEqual([
|
||||
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
await expect(stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("adds remote machines and omits secrets from public responses", async () => {
|
||||
@@ -38,8 +39,7 @@ describe("MachineService", () => {
|
||||
await expectOwnerOnlyMachineStore(storePath);
|
||||
});
|
||||
|
||||
it("tightens permissions after reading an existing machine store", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => {
|
||||
await writeFile(storePath, `${JSON.stringify({
|
||||
machines: [{
|
||||
id: "remote-1",
|
||||
|
||||
@@ -85,9 +85,11 @@ describe("registerPiPackageRoutes", () => {
|
||||
expect(missingSource.statusCode).toBe(400);
|
||||
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||
expect(blankSource.statusCode).toBe(400);
|
||||
expect(blankSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||
expect(invalidScope.statusCode).toBe(400);
|
||||
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
|
||||
expect(invalidUpdate.statusCode).toBe(400);
|
||||
expect(invalidUpdate.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||
expect(serviceMocks.install).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.remove).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.update).not.toHaveBeenCalled();
|
||||
|
||||
@@ -172,19 +172,30 @@ describe("PiWebPluginService", () => {
|
||||
});
|
||||
|
||||
it("skips duplicate plugin ids", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "one"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
const firstRoot = join(tempDir, "first-root");
|
||||
const secondRoot = join(tempDir, "second-root");
|
||||
await writePlugin(join(firstRoot, "duplicate"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "first.js" }] } },
|
||||
files: { "first.js": "export default {};" },
|
||||
});
|
||||
await writePlugin(join(tempDir, "plugins", "two"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
await writePlugin(join(secondRoot, "duplicate"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "second.js", machineSpecific: true }] } },
|
||||
files: { "second.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({
|
||||
roots: [
|
||||
{ path: firstRoot, source: "first", scope: "local" },
|
||||
{ path: secondRoot, source: "second", scope: "local" },
|
||||
],
|
||||
packageProvider: false,
|
||||
});
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
|
||||
expect(manifest.plugins).toEqual([
|
||||
expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }),
|
||||
]);
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
|
||||
|
||||
@@ -90,8 +90,7 @@ describe("PI WEB status", () => {
|
||||
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
||||
});
|
||||
|
||||
it("suggests native systemd commands for local development services", async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const home = await tempHome();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("auth provider options", () => {
|
||||
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes Anthropic in both OAuth and API key login options", () => {
|
||||
it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
|
||||
const options = getLoginProviderOptions(registry());
|
||||
expect(options).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
|
||||
@@ -44,7 +44,7 @@ describe("auth provider options", () => {
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
||||
});
|
||||
|
||||
it("returns only stored credentials for logout", () => {
|
||||
it("returns only currently stored credentials for logout", () => {
|
||||
expect(getLogoutProviderOptions(registry())).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
||||
]);
|
||||
|
||||
@@ -169,23 +169,6 @@ function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveS
|
||||
}
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
it("exposes the session's agent.streamFn for one-off model calls", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const streamFn = vi.fn();
|
||||
const fake = fakeRuntime("stream-session", { agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(fake.session.agent.streamFn).toBe(streamFn);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("starts sessions through an injected runtime creator", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime();
|
||||
@@ -957,14 +940,21 @@ describe("PiSessionService", () => {
|
||||
|
||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
let createCalls = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async () => {
|
||||
createCalls += 1;
|
||||
await Promise.resolve();
|
||||
return fake.runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
|
||||
|
||||
expect(createCalls).toBe(0);
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
@@ -1313,7 +1303,7 @@ describe("PiSessionService", () => {
|
||||
}
|
||||
|
||||
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace"); // bring the parent online so it can be notified
|
||||
|
||||
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
|
||||
@@ -1323,7 +1313,6 @@ describe("PiSessionService", () => {
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
|
||||
]);
|
||||
void parent;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -66,11 +66,15 @@ describe("SessionCommandService", () => {
|
||||
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
|
||||
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
|
||||
expect(prompt).toHaveBeenCalledTimes(3);
|
||||
expect(prompt).toHaveBeenNthCalledWith(1, "s1", "/ext arg");
|
||||
expect(prompt).toHaveBeenNthCalledWith(2, "s1", "/template arg");
|
||||
expect(prompt).toHaveBeenNthCalledWith(3, "s1", "/skill:skill-a arg");
|
||||
});
|
||||
|
||||
it("renames sessions and returns updated client session metadata", async () => {
|
||||
it("renames sessions, publishes the name update, and returns updated client session metadata", async () => {
|
||||
const active = activeSession();
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||
const events = eventPublisher();
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
|
||||
|
||||
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
|
||||
type: "done",
|
||||
@@ -78,6 +82,7 @@ describe("SessionCommandService", () => {
|
||||
session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 },
|
||||
});
|
||||
expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name");
|
||||
expect(events.publish).toHaveBeenCalledWith("s1", { type: "session.name", sessionId: "s1", name: "Useful name" });
|
||||
});
|
||||
|
||||
it("formats session stats", async () => {
|
||||
@@ -90,18 +95,22 @@ describe("SessionCommandService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts compaction and publishes completion", async () => {
|
||||
it("starts compaction, updates lifecycle hooks, and publishes completion", async () => {
|
||||
const active = activeSession();
|
||||
const events = eventPublisher();
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
|
||||
const onCompactionStart = vi.fn();
|
||||
const onCompactionEnd = vi.fn();
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), events, { onCompactionStart, onCompactionEnd });
|
||||
|
||||
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
|
||||
expect(onCompactionStart).toHaveBeenCalledWith(active.runtime.session);
|
||||
await vi.waitFor(() => {
|
||||
expect(events.publish).toHaveBeenCalledWith("s1", {
|
||||
type: "command.output",
|
||||
level: "success",
|
||||
message: "Compaction complete.\nTokens before: 123\n\nshort summary",
|
||||
});
|
||||
expect(onCompactionEnd).toHaveBeenCalledWith(active.runtime.session, "success");
|
||||
});
|
||||
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
|
||||
|
||||
describe("createSpawnSessionToolDefinition", () => {
|
||||
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
|
||||
it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("createSpawnSessionToolDefinition", () => {
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
|
||||
});
|
||||
|
||||
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
|
||||
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
@@ -29,15 +29,6 @@ describe("createSpawnSessionToolDefinition", () => {
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("omits the inherited model when the dispatching session has no current model", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
||||
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
@@ -81,12 +81,12 @@ describe("buildTranscriptView", () => {
|
||||
expect(callPart.args).toEqual({ command: "ls" });
|
||||
});
|
||||
|
||||
it("search keeps only matching entries across text and tool names", () => {
|
||||
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
|
||||
it("search keeps only entries matching text or tool-call names", () => {
|
||||
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read"), toolCall("auth-search")];
|
||||
const view = buildTranscriptView(messages, { search: "auth" });
|
||||
|
||||
expect(view.matched).toBe(2);
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
|
||||
expect(view.matched).toBe(3);
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it("search runs against full content even when maxChars would clip the match away", () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("terminal routes", () => {
|
||||
expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]);
|
||||
});
|
||||
|
||||
it("creates and lists terminal command runs with filters", async () => {
|
||||
it("routes command-run create, filter, cancel, and terminal continue requests", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/terminal-command-runs",
|
||||
|
||||
@@ -13,8 +13,7 @@ describe("normalizeRequestCwd", () => {
|
||||
expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase);
|
||||
});
|
||||
|
||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project");
|
||||
});
|
||||
|
||||
@@ -47,8 +46,7 @@ describe("cwdPathsEqual", () => {
|
||||
expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -130,13 +130,14 @@ describe("writeWorkspaceFile", () => {
|
||||
expect(content).toBe("const greeting = 'hello';\n");
|
||||
});
|
||||
|
||||
it("writes binary content", async () => {
|
||||
it("writes binary content without text re-encoding", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||
|
||||
const result = await writeWorkspaceFile(root, "image.png", binaryData);
|
||||
|
||||
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
|
||||
await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData);
|
||||
});
|
||||
|
||||
it("overwrites existing files by default", async () => {
|
||||
@@ -190,14 +191,12 @@ describe("writeWorkspaceFile", () => {
|
||||
it("prevents writing through symlinks that escape the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
// Create a symlink inside the workspace that points outside
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
// Attempting to write through the symlink should be blocked
|
||||
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
|
||||
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace");
|
||||
await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,7 +226,7 @@ describe("deleteWorkspaceFile", () => {
|
||||
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
|
||||
});
|
||||
|
||||
it("rejects path traversal", async () => {
|
||||
it("rejects traversal and absolute paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
@@ -253,7 +252,7 @@ describe("deleteWorkspaceFile", () => {
|
||||
|
||||
expect(result).toMatchObject({ path: "link.txt", existed: true });
|
||||
// The symlink should be gone, but the target file should still exist
|
||||
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
|
||||
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist");
|
||||
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
||||
expect(realContent).toBe("real content");
|
||||
});
|
||||
@@ -307,6 +306,8 @@ describe("moveWorkspaceFile", () => {
|
||||
await writeFile(join(root, "file.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
||||
const source = await readWorkspaceFile(root, "file.txt");
|
||||
expect(source.content).toBe("data");
|
||||
});
|
||||
|
||||
it("overwrites target when overwrite is true", async () => {
|
||||
@@ -327,9 +328,11 @@ describe("moveWorkspaceFile", () => {
|
||||
await writeFile(join(root, "target.txt"), "target");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
||||
// Source should still exist
|
||||
// Source and target should remain unchanged
|
||||
const source = await readWorkspaceFile(root, "source.txt");
|
||||
expect(source.content).toBe("source");
|
||||
const target = await readWorkspaceFile(root, "target.txt");
|
||||
expect(target.content).toBe("target");
|
||||
});
|
||||
|
||||
it("rejects source path traversal", async () => {
|
||||
@@ -342,7 +345,9 @@ describe("moveWorkspaceFile", () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
const source = await readWorkspaceFile(root, "source.txt");
|
||||
expect(source.content).toBe("data");
|
||||
});
|
||||
|
||||
it("rejects moving a directory", async () => {
|
||||
@@ -370,6 +375,9 @@ describe("moveWorkspaceFile", () => {
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
|
||||
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
|
||||
const source = await readWorkspaceFile(root, "subdir/file.txt");
|
||||
expect(source.content).toBe("data");
|
||||
await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("workspace deletion routes", () => {
|
||||
it("closes target workspace terminals before starting the deletion terminal command", async () => {
|
||||
it("closes target workspace terminals before starting deletion from the main workspace", async () => {
|
||||
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user