"""Tests for Terminal API routes""" from __future__ import annotations import pytest from fastapi import Request from fastapi.testclient import TestClient from agentkit.llm.gateway import LLMGateway from agentkit.server.app import create_app from agentkit.server.routes.terminal import ( _sessions, _check_command_safety, _is_dangerous, _is_single_command_dangerous, _get_or_create_session, _cleanup_session, ) from agentkit.skills.registry import SkillRegistry from agentkit.tools.registry import ToolRegistry # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def clear_sessions(): """Clear the global session store before each test.""" _sessions.clear() yield _sessions.clear() @pytest.fixture def mock_llm_gateway(): return LLMGateway() @pytest.fixture def skill_registry(): return SkillRegistry() @pytest.fixture def tool_registry(): return ToolRegistry() @pytest.fixture def app(mock_llm_gateway, skill_registry, tool_registry): application = create_app( llm_gateway=mock_llm_gateway, skill_registry=skill_registry, tool_registry=tool_registry, ) # Inject a dev-mode admin user so the terminal permission checks pass. # In production, the AuthMiddleware sets request.state.current_user from # the JWT; here we simulate an authenticated admin for unit testing. # The ``dev_mode: True`` flag skips the DB-based is_terminal_authorized # check in require_terminal_authorized (we don't have a real auth DB # in these unit tests). @application.middleware("http") async def _set_dev_admin_user(request: Request, call_next): request.state.current_user = { "user_id": "dev-admin", "username": "dev-admin", "role": "admin", "dev_mode": True, } return await call_next(request) return application @pytest.fixture def client(app): return TestClient(app) # --------------------------------------------------------------------------- # Security helper unit tests # --------------------------------------------------------------------------- class TestIsSingleCommandDangerous: def test_safe_command_ls(self): assert _is_single_command_dangerous("ls") is False def test_safe_command_pwd(self): assert _is_single_command_dangerous("pwd") is False def test_safe_command_git_status(self): assert _is_single_command_dangerous("git status") is False def test_safe_command_echo(self): assert _is_single_command_dangerous("echo hello") is False def test_safe_command_cat(self): assert _is_single_command_dangerous("cat file.txt") is False def test_dangerous_command_rm(self): assert _is_single_command_dangerous("rm file.txt") is True def test_dangerous_command_rm_rf(self): assert _is_single_command_dangerous("rm -rf /") is True def test_dangerous_command_mkfs(self): assert _is_single_command_dangerous("mkfs.ext4 /dev/sda1") is True def test_dangerous_command_git_push_force(self): assert _is_single_command_dangerous("git push --force") is True def test_safe_command_git_add(self): # git add is safe even though git push --force is not assert _is_single_command_dangerous("git add .") is False def test_dangerous_command_kill_9(self): assert _is_single_command_dangerous("kill -9 1234") is True def test_safe_command_kill_normal(self): # kill without dangerous flags is safe assert _is_single_command_dangerous("kill 1234") is False def test_dangerous_command_pip_uninstall(self): assert _is_single_command_dangerous("pip uninstall package") is True def test_safe_command_pip_list(self): assert _is_single_command_dangerous("pip list") is False def test_dangerous_command_dd(self): assert _is_single_command_dangerous("dd if=/dev/zero of=/dev/sda") is True def test_unknown_command_is_dangerous(self): assert _is_single_command_dangerous("unknown_binary") is True def test_empty_command_is_dangerous(self): assert _is_single_command_dangerous("") is True def test_safe_command_with_args(self): assert _is_single_command_dangerous("ls -la /tmp") is False def test_safe_command_docker_ps(self): assert _is_single_command_dangerous("docker ps") is False def test_dangerous_command_docker_rm(self): assert _is_single_command_dangerous("docker rm container") is True class TestIsDangerous: def test_safe_single_command(self): assert _is_dangerous("ls -la") is False def test_dangerous_single_command(self): assert _is_dangerous("rm -rf /") is True def test_safe_pipe(self): assert _is_dangerous("ls | grep foo") is False def test_dangerous_pipe(self): assert _is_dangerous("ls | rm") is True def test_chain_operator_is_dangerous(self): assert _is_dangerous("ls ; rm -rf /") is True def test_and_operator_is_dangerous(self): assert _is_dangerous("ls && rm -rf /") is True def test_or_operator_is_dangerous(self): assert _is_dangerous("ls || rm -rf /") is True def test_command_substitution_is_dangerous(self): assert _is_dangerous("echo $(rm -rf /)") is True def test_redirection_is_dangerous(self): assert _is_dangerous("echo foo > /etc/passwd") is True def test_safe_pipe_all_safe(self): assert _is_dangerous("cat file.txt | grep pattern | sort") is False class TestCheckCommandSafety: def test_safe_command(self): result = _check_command_safety("ls", "test-session") assert result["safe"] is True def test_dangerous_command(self): result = _check_command_safety("rm -rf /", "test-session") assert result["safe"] is False assert "reason" in result def test_session_whitelist(self): # Create a session with whitelist state = _get_or_create_session("test-session") state.whitelist.add("rm") result = _check_command_safety("rm file.txt", "test-session") assert result["safe"] is True def test_session_whitelist_prefix_match(self): state = _get_or_create_session("test-session") state.whitelist.add("docker") result = _check_command_safety("docker rm container", "test-session") assert result["safe"] is True # --------------------------------------------------------------------------- # Session management tests # --------------------------------------------------------------------------- class TestSessionManagement: def test_get_or_create_new(self): state = _get_or_create_session() assert state.session_id is not None assert state.cwd != "" def test_get_or_create_with_id(self): state = _get_or_create_session("my-session") assert state.session_id == "my-session" # Get existing state2 = _get_or_create_session("my-session") assert state2.session_id == "my-session" assert state is state2 def test_cleanup_session(self): _get_or_create_session("cleanup-test") assert "cleanup-test" in _sessions _cleanup_session("cleanup-test") assert "cleanup-test" not in _sessions def test_cleanup_nonexistent_session(self): # Should not raise _cleanup_session("nonexistent") # --------------------------------------------------------------------------- # REST endpoint tests # --------------------------------------------------------------------------- class TestExecuteEndpoint: def test_execute_safe_command(self, client): response = client.post( "/api/v1/terminal/execute", json={"command": "echo hello"}, ) assert response.status_code == 200 data = response.json() assert data["exit_code"] == 0 assert "hello" in data["output"] assert data["confirmation_required"] is False assert data["session_id"] is not None def test_execute_dangerous_command_returns_confirmation(self, client): response = client.post( "/api/v1/terminal/execute", json={"command": "rm -rf /"}, ) assert response.status_code == 200 data = response.json() assert data["confirmation_required"] is True assert data["exit_code"] == 126 assert data["reason"] is not None def test_execute_with_session_id(self, client): # First request creates session resp1 = client.post( "/api/v1/terminal/execute", json={"command": "echo first", "session_id": "test-session-1"}, ) assert resp1.status_code == 200 assert resp1.json()["session_id"] == "test-session-1" # Second request reuses session resp2 = client.post( "/api/v1/terminal/execute", json={"command": "echo second", "session_id": "test-session-1"}, ) assert resp2.status_code == 200 assert resp2.json()["session_id"] == "test-session-1" def test_execute_empty_command(self, client): response = client.post( "/api/v1/terminal/execute", json={"command": ""}, ) # Empty command is dangerous, so confirmation_required assert response.status_code == 200 data = response.json() assert data["confirmation_required"] is True def test_execute_pwd_command(self, client): response = client.post( "/api/v1/terminal/execute", json={"command": "pwd"}, ) assert response.status_code == 200 data = response.json() assert data["exit_code"] == 0 assert data["output"].strip() != "" class TestListSessionsEndpoint: def test_list_empty(self, client): response = client.get("/api/v1/terminal/sessions") assert response.status_code == 200 data = response.json() assert data["sessions"] == [] def test_list_after_execute(self, client): client.post( "/api/v1/terminal/execute", json={"command": "echo hello", "session_id": "list-test"}, ) response = client.get("/api/v1/terminal/sessions") assert response.status_code == 200 data = response.json() assert len(data["sessions"]) >= 1 session_ids = [s["session_id"] for s in data["sessions"]] assert "list-test" in session_ids class TestHistoryEndpoint: def test_history_after_execute(self, client): client.post( "/api/v1/terminal/execute", json={"command": "echo hello", "session_id": "history-test"}, ) response = client.get("/api/v1/terminal/sessions/history-test/history") assert response.status_code == 200 data = response.json() assert len(data["history"]) == 1 assert data["history"][0]["command"] == "echo hello" assert data["history"][0]["exit_code"] == 0 def test_history_not_found(self, client): response = client.get("/api/v1/terminal/sessions/nonexistent/history") assert response.status_code == 404 def test_history_multiple_commands(self, client): client.post( "/api/v1/terminal/execute", json={"command": "echo first", "session_id": "multi-test"}, ) client.post( "/api/v1/terminal/execute", json={"command": "echo second", "session_id": "multi-test"}, ) response = client.get("/api/v1/terminal/sessions/multi-test/history") assert response.status_code == 200 data = response.json() assert len(data["history"]) == 2 class TestCloseSessionEndpoint: def test_close_existing_session(self, client): client.post( "/api/v1/terminal/execute", json={"command": "echo hello", "session_id": "close-test"}, ) response = client.delete("/api/v1/terminal/sessions/close-test") assert response.status_code == 200 data = response.json() assert data["status"] == "closed" def test_close_not_found(self, client): response = client.delete("/api/v1/terminal/sessions/nonexistent") assert response.status_code == 404