Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
4f2d93773f test(security-scanner): add TTY prompt tests for warning behavior
Add tests for the security scanner's interactive TTY prompt that appears
when warnings are found. Previously, TTY behavior was untested because
we lacked a way to simulate a terminal environment.

This adds:
- security-scanner-pty.py: Python script to create PTY environment
- bun-security-scanner-tty.test.ts: Tests for TTY and non-TTY behavior
  - Verifies prompt appears in TTY mode
  - Tests 'y'/'Y' continues installation
  - Tests 'n'/'N'/Enter/other cancels installation
  - Tests non-TTY mode fails immediately without prompting
  - Verifies packages are installed/not installed accordingly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 21:04:53 +00:00
2 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,284 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDirWithFiles } from "harness";
import { dirname, join } from "node:path";
import { getRegistry, startRegistry, stopRegistry } from "./simple-dummy-registry";
const __dirname = dirname(Bun.fileURLToPath(import.meta.url));
const ptyScript = join(__dirname, "security-scanner-pty.py");
let registryUrl: string;
beforeAll(async () => {
registryUrl = await startRegistry(false);
});
afterAll(() => {
stopRegistry();
});
/**
* Run bun install in a PTY environment with a security scanner that returns warnings.
* This allows testing the interactive prompt behavior.
*
* @param response - The response to send when prompted: 'y', 'n', 'Y', 'N', 'enter', 'other', or 'timeout'
* @returns Object containing stdout, stderr, exitCode, and parsed markers
*/
async function runWithPty(response: string): Promise<{
stdout: string;
stderr: string;
exitCode: number;
promptDetected: boolean;
sentResponse: string | null;
dir: string;
}> {
const registry = getRegistry();
if (!registry) {
throw new Error("Registry not started");
}
registry.clearRequestLog();
registry.setScannerBehavior("warn");
// Create a test directory with a package.json and scanner
const scannerCode = `export const scanner = {
version: "1",
scan: async function(payload) {
if (payload.packages.length > 0) {
return [{
package: payload.packages[0].name,
level: "warn",
description: "Test warning for TTY prompt"
}];
}
return [];
}
};`;
const dir = tempDirWithFiles("scanner-tty", {
"package.json": JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: {
"left-pad": "1.3.0",
},
}),
"scanner.js": scannerCode,
"bunfig.toml": `[install]
cache.disable = true
registry = "${registryUrl}/"
[install.security]
scanner = "./scanner.js"`,
});
const python = Bun.which("python3") ?? Bun.which("python");
if (!python) {
throw new Error("Python not found");
}
await using proc = Bun.spawn({
cmd: [python, ptyScript, bunExe(), dir, response],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Parse markers from stdout
const promptDetected = stdout.includes("PTY_PROMPT_DETECTED");
const sentResponseMatch = stdout.match(/PTY_SENT_RESPONSE: (\S+)/);
const sentResponse = sentResponseMatch ? sentResponseMatch[1] : null;
return {
stdout,
stderr,
exitCode,
promptDetected,
sentResponse,
dir,
};
}
/**
* Run bun install WITHOUT a PTY (piped stdin) to verify non-TTY behavior.
* In non-TTY mode, warnings should cause immediate failure without prompting.
*/
async function runWithoutPty(): Promise<{
stdout: string;
stderr: string;
exitCode: number;
dir: string;
}> {
const registry = getRegistry();
if (!registry) {
throw new Error("Registry not started");
}
registry.clearRequestLog();
registry.setScannerBehavior("warn");
// Create a test directory with a package.json and scanner
const scannerCode = `export const scanner = {
version: "1",
scan: async function(payload) {
if (payload.packages.length > 0) {
return [{
package: payload.packages[0].name,
level: "warn",
description: "Test warning for non-TTY"
}];
}
return [];
}
};`;
const dir = tempDirWithFiles("scanner-no-tty", {
"package.json": JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: {
"left-pad": "1.3.0",
},
}),
"scanner.js": scannerCode,
"bunfig.toml": `[install]
cache.disable = true
registry = "${registryUrl}/"
[install.security]
scanner = "./scanner.js"`,
});
// Run without PTY - stdin is piped, not a TTY
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
stdin: "pipe", // This ensures stdin is NOT a TTY
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return {
stdout,
stderr,
exitCode,
dir,
};
}
describe.skipIf(isWindows)("Security Scanner Non-TTY Behavior", () => {
test("fails immediately with warning when no TTY available (cannot prompt)", async () => {
const result = await runWithoutPty();
// Should show the "no TTY" error message
expect(result.stdout).toContain("Security warnings found. Cannot prompt for confirmation (no TTY).");
expect(result.stdout).toContain("Installation cancelled.");
// Should NOT show the prompt
expect(result.stdout).not.toContain("Continue anyway? [y/N]");
// Verify package was NOT installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(false);
expect(result.exitCode).toBe(1);
});
});
describe.skipIf(isWindows)("Security Scanner TTY Prompt", () => {
test("shows prompt when TTY is available and warnings are found", async () => {
const result = await runWithPty("y");
expect(result.promptDetected).toBe(true);
expect(result.stdout).toContain("Security warnings found.");
expect(result.stdout).toContain("Continue anyway? [y/N]");
});
test("continues installation when user responds 'y'", async () => {
const result = await runWithPty("y");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("y");
expect(result.stdout).toContain("Continuing with installation...");
// Verify package was installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(true);
expect(result.exitCode).toBe(0);
});
test("continues installation when user responds 'Y'", async () => {
const result = await runWithPty("Y");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("Y");
expect(result.stdout).toContain("Continuing with installation...");
// Verify package was installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(true);
expect(result.exitCode).toBe(0);
});
test("cancels installation when user responds 'n'", async () => {
const result = await runWithPty("n");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("n");
expect(result.stdout).toContain("Installation cancelled.");
// Verify package was NOT installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(false);
expect(result.exitCode).toBe(1);
});
test("cancels installation when user responds 'N'", async () => {
const result = await runWithPty("N");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("N");
expect(result.stdout).toContain("Installation cancelled.");
// Verify package was NOT installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(false);
expect(result.exitCode).toBe(1);
});
test("cancels installation when user just presses Enter (default)", async () => {
const result = await runWithPty("enter");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("enter");
expect(result.stdout).toContain("Installation cancelled.");
// Verify package was NOT installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(false);
expect(result.exitCode).toBe(1);
});
test("cancels installation when user responds with other characters", async () => {
const result = await runWithPty("other");
expect(result.promptDetected).toBe(true);
expect(result.sentResponse).toBe("other");
expect(result.stdout).toContain("Installation cancelled.");
// Verify package was NOT installed
const packageJsonPath = join(result.dir, "node_modules", "left-pad", "package.json");
expect(await Bun.file(packageJsonPath).exists()).toBe(false);
expect(result.exitCode).toBe(1);
});
});

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
PTY wrapper for security scanner TTY tests.
This script creates a pseudo-terminal and runs bun install with security scanner
configured. It allows testing the interactive prompt behavior when warnings are found.
Usage: python3 security-scanner-pty.py <bun_executable> <cwd> <response>
Where:
- bun_executable: Path to the bun executable
- cwd: Working directory for the install command
- response: One of 'y', 'n', 'Y', 'N', 'enter', 'timeout', 'other'
"""
import pty
import os
import sys
import select
import signal
import time
def main():
if len(sys.argv) < 4:
print("Usage: python3 security-scanner-pty.py <bun_exe> <cwd> <response>", file=sys.stderr)
sys.exit(1)
bun_exe = sys.argv[1]
cwd = sys.argv[2]
response = sys.argv[3]
# Open a pseudo-terminal
master_fd, slave_fd = pty.openpty()
pid = os.fork()
if pid == 0:
# Child process
os.close(master_fd)
os.setsid()
# Redirect stdin/stdout/stderr to the slave PTY
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
if slave_fd > 2:
os.close(slave_fd)
os.chdir(cwd)
os.execvp(bun_exe, [bun_exe, "install"])
else:
# Parent process
os.close(slave_fd)
# Set up timeout handler
def timeout_handler(signum, frame):
print("PTY_TIMEOUT", flush=True)
try:
os.kill(pid, signal.SIGKILL)
except:
pass
sys.exit(1)
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(30) # 30 second timeout
output = b""
prompt_found = False
try:
while True:
# Wait for data from the child process
ready = select.select([master_fd], [], [], 0.1)[0]
if ready:
try:
data = os.read(master_fd, 4096)
if data:
output += data
# Print output for debugging
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
# Check if we see the prompt
if b"Continue anyway? [y/N]" in output and not prompt_found:
prompt_found = True
print("\nPTY_PROMPT_DETECTED", flush=True)
# Small delay to ensure the prompt is fully displayed
time.sleep(0.1)
# Send the response based on the argument
if response == "y":
os.write(master_fd, b"y\n")
elif response == "Y":
os.write(master_fd, b"Y\n")
elif response == "n":
os.write(master_fd, b"n\n")
elif response == "N":
os.write(master_fd, b"N\n")
elif response == "enter":
os.write(master_fd, b"\n")
elif response == "other":
os.write(master_fd, b"x\n")
elif response == "timeout":
# Don't send anything, let it hang
pass
else:
os.write(master_fd, response.encode() + b"\n")
print(f"PTY_SENT_RESPONSE: {response}", flush=True)
else:
# EOF from child
break
except OSError:
break
# Check if child has exited
result = os.waitpid(pid, os.WNOHANG)
if result[0] != 0:
# Read any remaining output
try:
while True:
ready = select.select([master_fd], [], [], 0.1)[0]
if ready:
data = os.read(master_fd, 4096)
if data:
output += data
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
else:
break
else:
break
except:
pass
break
except Exception as e:
print(f"PTY_ERROR: {e}", file=sys.stderr, flush=True)
finally:
signal.alarm(0) # Cancel timeout
os.close(master_fd)
# Wait for child to fully exit and get exit code
try:
_, status = os.waitpid(pid, 0)
exit_code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
except ChildProcessError:
# Already reaped
exit_code = 0
print(f"\nPTY_EXIT_CODE: {exit_code}", flush=True)
if not prompt_found:
print("PTY_NO_PROMPT_FOUND", flush=True)
sys.exit(exit_code)
if __name__ == "__main__":
main()