pms-infra-agent-process-0.0.2.0: test/PMS/Infra/Agent/Process/App/ControlSpec.hs
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
module PMS.Infra.Agent.Process.App.ControlSpec (spec) where
import Test.Hspec
import Control.Concurrent
import Control.Concurrent.Async
import qualified Control.Concurrent.STM as STM
import Control.Lens
import Data.Default
import Data.Aeson (encode)
import qualified PMS.Domain.Model.DM.Type as DM
import qualified PMS.Infra.Agent.Process.App.Control as SUT
import qualified PMS.Infra.Agent.Process.DM.Type as SUT
-- |
--
data SpecContext = SpecContext {
_domainDataSpecContext :: DM.DomainData
, _threadIdSpecContext :: Maybe (Async ())
}
makeLenses ''SpecContext
defaultSpecContext :: IO SpecContext
defaultSpecContext = do
domDat <- DM.defaultDomainData
return SpecContext {
_domainDataSpecContext = domDat
, _threadIdSpecContext = Nothing
}
-- |
--
spec :: Spec
spec = do
runIO $ putStrLn "Start Spec."
beforeAll setUpOnce $
afterAll tearDownOnce .
beforeWith setUp .
after tearDown $ run
-- |
--
setUpOnce :: IO SpecContext
setUpOnce = do
putStrLn "[INFO] EXECUTED ONLY ONCE BEFORE ALL TESTS START."
defaultSpecContext
-- |
--
tearDownOnce :: SpecContext -> IO ()
tearDownOnce _ = do
putStrLn "[INFO] EXECUTED ONLY ONCE AFTER ALL TESTS FINISH."
-- |
-- Each test gets a fresh domDat/appDat and a running SUT thread.
-- cmd.exe is added to the whitelist so that TC-02 / TC-04 keep working.
--
setUp :: SpecContext -> IO SpecContext
setUp ctx = do
putStrLn "[INFO] EXECUTED BEFORE EACH TEST STARTS."
domDat <- DM.defaultDomainData
let domDat' = domDat { DM._allowedAgentCmdsDomainData = ["cmd.exe"] }
appDat <- SUT.defaultAppData
thId <- async $ SUT.runWithAppData appDat domDat'
return ctx {
_domainDataSpecContext = domDat'
, _threadIdSpecContext = Just thId
}
-- |
-- Build a SpecContext with a custom whitelist and start the SUT thread.
--
setUpWithAllowList :: SpecContext -> [String] -> IO SpecContext
setUpWithAllowList ctx allowList = do
domDat <- DM.defaultDomainData
let domDat' = domDat { DM._allowedAgentCmdsDomainData = allowList }
appDat <- SUT.defaultAppData
thId <- async $ SUT.runWithAppData appDat domDat'
return ctx {
_domainDataSpecContext = domDat'
, _threadIdSpecContext = Just thId
}
-- |
-- Cancel the SUT thread after each test.
--
tearDown :: SpecContext -> IO ()
tearDown ctx = do
putStrLn "[INFO] EXECUTED AFTER EACH TEST FINISHES."
case ctx^.threadIdSpecContext of
Nothing -> return ()
Just thId -> cancel thId
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- |
-- Send a command to the SUT and receive one response.
--
sendAndReceive :: DM.DomainData
-> DM.AgentProcessCommand
-> IO DM.McpToolsCallResponseData
sendAndReceive domDat cmd = do
let cmdQ = domDat^.DM.agentProcessQueueDomainData
resQ = domDat^.DM.responseQueueDomainData
STM.atomically $ STM.writeTQueue cmdQ cmd
(DM.McpToolsCallResponse dat) <- STM.atomically $ STM.readTQueue resQ
return dat
-- |
-- Build a minimal JsonRpcRequest for testing.
--
mkJsonRpc :: String -> DM.JsonRpcRequest
mkJsonRpc method = def { DM._methodJsonRpcRequest = method }
-- |
-- Encode ProcRunToolParams to RawJsonByteString.
--
mkRunArgs :: String -> [String] -> DM.RawJsonByteString
mkRunArgs cmd args =
DM.RawJsonByteString $ encode $ SUT.ProcRunToolParams
{ SUT._commandProcRunToolParams = cmd
, SUT._argumentsProcRunToolParams = Just args
, SUT._environmentProcRunToolParams = Nothing
}
-- |
-- Encode ProcIntToolParams (read size) to RawJsonByteString.
--
mkReadArgs :: Int -> DM.RawJsonByteString
mkReadArgs size =
DM.RawJsonByteString $ encode $ SUT.ProcIntToolParams
{ SUT._argumentsProcIntToolParams = size }
-- |
-- Encode ProcWriteToolParams (write payload) to RawJsonByteString.
--
mkWriteArgs :: String -> DM.RawJsonByteString
mkWriteArgs s = mkWriteArgsWithAppendNewline s Nothing
-- |
-- Encode ProcWriteToolParams with explicit appendNewline.
--
mkWriteArgsWithAppendNewline :: String -> Maybe Bool -> DM.RawJsonByteString
mkWriteArgsWithAppendNewline s appendNewline =
DM.RawJsonByteString $ encode $ SUT.ProcWriteToolParams
{ SUT._dataProcWriteToolParams = s
, SUT._appendNewlineProcWriteToolParams = appendNewline
}
-- |
-- Extract the isError flag from a response.
--
isError :: DM.McpToolsCallResponseData -> Bool
isError dat =
dat^.DM.resultMcpToolsCallResponseData^.DM.isErrorMcpToolsCallResponseResult
-- |
-- Extract the first text content from a response.
--
firstContentText :: DM.McpToolsCallResponseData -> String
firstContentText dat =
let contents = dat^.DM.resultMcpToolsCallResponseData^.DM.contentMcpToolsCallResponseResult
in DM._textMcpToolsCallResponseResultContent (head contents)
-- |
-- Terminate the running process via agent-proc-terminate.
--
terminateProc :: DM.DomainData -> IO ()
terminateProc domDat = do
_ <- sendAndReceive domDat $
DM.AgentProcessTerminateCommand $ DM.AgentProcessTerminateCommandData
{ DM._jsonrpcAgentProcessTerminateCommandData = mkJsonRpc "agent-proc-terminate"
}
return ()
-- ---------------------------------------------------------------------------
-- Test suite
-- ---------------------------------------------------------------------------
-- |
--
run :: SpecWith SpecContext
run = do
-- TC-01: echo command smoke test
describe "TC-01: echo command" $ do
context "when AgentProcessEchoCommand is sent" $ do
it "should return the same value with isError=False" $ \ctx -> do
putStrLn "[INFO] TC-01 start."
let domDat = ctx^.domainDataSpecContext
expect = "hello-echo"
echoCmd = DM.AgentProcessEchoCommand $ DM.AgentProcessEchoCommandData
{ DM._jsonrpcAgentProcessEchoCommandData = mkJsonRpc "echo"
, DM._valueAgentProcessEchoCommandData = expect
}
dat <- sendAndReceive domDat echoCmd
isError dat `shouldBe` False
let contents = dat^.DM.resultMcpToolsCallResponseData^.DM.contentMcpToolsCallResponseResult
actual = DM._textMcpToolsCallResponseResultContent (head contents)
actual `shouldBe` expect
-- TC-02: run -> read -> write -> read -> terminate smoke test
describe "TC-02: run -> read -> write -> read -> terminate" $ do
context "when cmd.exe is started and interacted with" $ do
it "should succeed for each step with isError=False" $ \ctx -> do
putStrLn "[INFO] TC-02 start."
let domDat = ctx^.domainDataSpecContext
-- run
let runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` False
-- read (discard the initial prompt output after cmd.exe starts)
threadDelay (500 * 1000) -- wait 500ms for the process to start
let readCmd = DM.AgentProcessReadCommand $ DM.AgentProcessReadCommandData
{ DM._jsonrpcAgentProcessReadCommandData = mkJsonRpc "agent-proc-read"
, DM._argumentsAgentProcessReadCommandData = mkReadArgs 4096
}
readDat1 <- sendAndReceive domDat readCmd
isError readDat1 `shouldBe` False
-- write: send "echo hello\n"
let writeCmd = DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData = mkWriteArgs "echo hello"
}
writeDat <- sendAndReceive domDat writeCmd
isError writeDat `shouldBe` False
-- read (read the echo output)
threadDelay (500 * 1000) -- wait 500ms for the output to be available
readDat2 <- sendAndReceive domDat readCmd
isError readDat2 `shouldBe` False
-- terminate
let termCmd = DM.AgentProcessTerminateCommand $ DM.AgentProcessTerminateCommandData
{ DM._jsonrpcAgentProcessTerminateCommandData = mkJsonRpc "agent-proc-terminate"
}
termDat <- sendAndReceive domDat termCmd
isError termDat `shouldBe` False
-- TC-03: terminate without prior run
describe "TC-03: terminate without prior run" $ do
context "when terminate is sent before run" $ do
it "should return isError=True" $ \ctx -> do
putStrLn "[INFO] TC-03 start."
let domDat = ctx^.domainDataSpecContext
termCmd = DM.AgentProcessTerminateCommand $ DM.AgentProcessTerminateCommandData
{ DM._jsonrpcAgentProcessTerminateCommandData = mkJsonRpc "agent-proc-terminate"
}
dat <- sendAndReceive domDat termCmd
isError dat `shouldBe` True
-- TC-04: double run (process already running)
describe "TC-04: double run" $ do
context "when run is sent twice" $ do
it "should return isError=True for the second run" $ \ctx -> do
putStrLn "[INFO] TC-04 start."
let domDat = ctx^.domainDataSpecContext
runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
-- first run: should succeed
runDat1 <- sendAndReceive domDat runCmd
isError runDat1 `shouldBe` False
-- second run: should fail with error
runDat2 <- sendAndReceive domDat runCmd
isError runDat2 `shouldBe` True
-- cleanup: terminate
let termCmd = DM.AgentProcessTerminateCommand $ DM.AgentProcessTerminateCommandData
{ DM._jsonrpcAgentProcessTerminateCommandData = mkJsonRpc "agent-proc-terminate"
}
_ <- sendAndReceive domDat termCmd
return ()
-- TC-05: Whitelist - allowed command succeeds
describe "TC-05: whitelist - allowed command" $ do
context "when cmd.exe is in agentAllowedCmds" $ do
it "should start the process and return isError=False" $ \ctx -> do
putStrLn "[INFO] TC-05 start."
ctx' <- setUpWithAllowList ctx ["cmd.exe"]
let domDat = ctx'^.domainDataSpecContext
runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` False
-- cleanup
terminateProc domDat
tearDown ctx'
-- TC-06: Whitelist - denied command returns error message
describe "TC-06: whitelist - denied command" $ do
context "when powershell.exe is NOT in agentAllowedCmds" $ do
it "should return isError=True with 'command not allowed' message" $ \ctx -> do
putStrLn "[INFO] TC-06 start."
ctx' <- setUpWithAllowList ctx ["cmd.exe"]
let domDat = ctx'^.domainDataSpecContext
runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "powershell.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` True
let contents = runDat^.DM.resultMcpToolsCallResponseData^.DM.contentMcpToolsCallResponseResult
errText = DM._textMcpToolsCallResponseResultContent (head contents)
errText `shouldBe` "cmd2task: exception occurred. skip. command not allowed: powershell.exe"
tearDown ctx'
-- TC-07: Whitelist - empty list denies all commands
describe "TC-07: whitelist - empty list denies all" $ do
context "when agentAllowedCmds is empty" $ do
it "should return isError=True for any command" $ \ctx -> do
putStrLn "[INFO] TC-07 start."
ctx' <- setUpWithAllowList ctx []
let domDat = ctx'^.domainDataSpecContext
runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` True
let contents = runDat^.DM.resultMcpToolsCallResponseData^.DM.contentMcpToolsCallResponseResult
errText = DM._textMcpToolsCallResponseResultContent (head contents)
errText `shouldBe` "cmd2task: exception occurred. skip. command not allowed: cmd.exe"
tearDown ctx'
-- ---------------------------------------------------------------------------
-- CR-11: appendNewline tests
-- ---------------------------------------------------------------------------
-- TC-A: appendNewline omitted -> newline is appended and cmd.exe executes input
describe "TC-A: agent-proc-write appendNewline default appends newline" $ do
context "when appendNewline is omitted and input has no trailing newline" $ do
it "should execute the command because newline is appended" $ \ctx -> do
putStrLn "[INFO] TC-A start."
let domDat = ctx^.domainDataSpecContext
token = "pms-cr11-default-newline"
let runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` False
threadDelay (500 * 1000)
let readCmd = DM.AgentProcessReadCommand $ DM.AgentProcessReadCommandData
{ DM._jsonrpcAgentProcessReadCommandData = mkJsonRpc "agent-proc-read"
, DM._argumentsAgentProcessReadCommandData = mkReadArgs 4096
}
_ <- sendAndReceive domDat readCmd
let writeCmd = DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData = mkWriteArgs ("echo " ++ token)
}
writeDat <- sendAndReceive domDat writeCmd
isError writeDat `shouldBe` False
threadDelay (500 * 1000)
readDat <- sendAndReceive domDat readCmd
isError readDat `shouldBe` False
firstContentText readDat `shouldContain` token
terminateProc domDat
-- TC-B: appendNewline False -> no newline is appended and cmd.exe keeps input pending
describe "TC-B: agent-proc-write appendNewline=False suppresses newline" $ do
context "when appendNewline is Just False" $ do
it "should not execute the command because newline is not appended" $ \ctx -> do
putStrLn "[INFO] TC-B start."
let domDat = ctx^.domainDataSpecContext
token = "pms-cr11-no-newline"
let runCmd = DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
runDat <- sendAndReceive domDat runCmd
isError runDat `shouldBe` False
threadDelay (500 * 1000)
let readCmd = DM.AgentProcessReadCommand $ DM.AgentProcessReadCommandData
{ DM._jsonrpcAgentProcessReadCommandData = mkJsonRpc "agent-proc-read"
, DM._argumentsAgentProcessReadCommandData = mkReadArgs 4096
}
_ <- sendAndReceive domDat readCmd
let writeCmd = DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData =
mkWriteArgsWithAppendNewline ("echo " ++ token) (Just False)
}
writeDat <- sendAndReceive domDat writeCmd
isError writeDat `shouldBe` False
threadDelay (500 * 1000)
readDat <- sendAndReceive domDat readCmd
isError readDat `shouldBe` False
firstContentText readDat `shouldNotContain` token
terminateProc domDat
-- ---------------------------------------------------------------------------
-- CR-12: invalidPatterns tests
-- ---------------------------------------------------------------------------
-- TC-08: invalidPatterns - matching input -> isError=True
describe "TC-08: invalidPatterns - matching input is rejected" $ do
context "when agent-proc-write input matches an invalidPatterns entry" $ do
it "should return isError=True" $ \ctx -> do
putStrLn "[INFO] TC-08 start."
domDat <- DM.defaultDomainData
let domDat' = domDat
{ DM._allowedAgentCmdsDomainData = ["cmd.exe"]
, DM._invalidPatternsDomainData = ["rm", "shutdown"]
}
appDat <- SUT.defaultAppData
thId <- async $ SUT.runWithAppData appDat domDat'
let ctx' = ctx { _domainDataSpecContext = domDat'
, _threadIdSpecContext = Just thId
}
dat <- sendAndReceive domDat' $
DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData = mkWriteArgs "rm -rf /"
}
isError dat `shouldBe` True
-- No process was started, just cancel the SUT thread.
tearDown ctx'
-- TC-09: invalidPatterns - non-matching input -> normal processing
describe "TC-09: invalidPatterns - non-matching input is allowed" $ do
context "when agent-proc-write input does NOT match any invalidPatterns entry" $ do
it "should return isError=False when process is running" $ \ctx -> do
putStrLn "[INFO] TC-09 start."
domDat <- DM.defaultDomainData
let domDat' = domDat
{ DM._allowedAgentCmdsDomainData = ["cmd.exe"]
, DM._invalidPatternsDomainData = ["rm", "shutdown"]
}
appDat <- SUT.defaultAppData
thId <- async $ SUT.runWithAppData appDat domDat'
let ctx' = ctx { _domainDataSpecContext = domDat'
, _threadIdSpecContext = Just thId
}
_ <- sendAndReceive domDat' $
DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
dat <- sendAndReceive domDat' $
DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData = mkWriteArgs "echo hello"
}
isError dat `shouldBe` False
-- cleanup: terminate cmd.exe before cancelling SUT thread
terminateProc domDat'
tearDown ctx'
-- TC-10: invalidPatterns - empty list -> allow all
describe "TC-10: invalidPatterns - empty list allows all input" $ do
context "when invalidPatterns is empty" $ do
it "should not reject any write input" $ \ctx -> do
putStrLn "[INFO] TC-10 start."
domDat <- DM.defaultDomainData
let domDat' = domDat
{ DM._allowedAgentCmdsDomainData = ["cmd.exe"]
, DM._invalidPatternsDomainData = []
}
appDat <- SUT.defaultAppData
thId <- async $ SUT.runWithAppData appDat domDat'
let ctx' = ctx { _domainDataSpecContext = domDat'
, _threadIdSpecContext = Just thId
}
_ <- sendAndReceive domDat' $
DM.AgentProcessRunCommand $ DM.AgentProcessRunCommandData
{ DM._jsonrpcAgentProcessRunCommandData = mkJsonRpc "agent-proc-run"
, DM._nameAgentProcessRunCommandData = "agent-proc-run"
, DM._argumentsAgentProcessRunCommandData = mkRunArgs "cmd.exe" []
}
dat <- sendAndReceive domDat' $
DM.AgentProcessWriteCommand $ DM.AgentProcessWriteCommandData
{ DM._jsonrpcAgentProcessWriteCommandData = mkJsonRpc "agent-proc-write"
, DM._argumentsAgentProcessWriteCommandData = mkWriteArgs "rm -rf /"
}
isError dat `shouldBe` False
-- cleanup: terminate cmd.exe before cancelling SUT thread
terminateProc domDat'
tearDown ctx'