{-# LANGUAGE OverloadedStrings #-} module Main (main) where import ChuSQL.Interface.Link (closeClient, connectClient) import ChuSQL.Interface.Auth (SessionPolicy (..), defaultSessionPolicy, lookupPayload, setPayloadPolicy) import ChuSQL.Interface.RateLimit (newRateLimiter) import ChuSQL.Interface.Session (authenticateSession, newSession, runStatement) import qualified ChuSQL.Interface.Actions as Actions import qualified ChuSQL.Core.Model as Model import ChuSQL.Core.Runtime import ChuSQL.Interface.Settings ( applySettings, defaultOf, isLockedSetting, isRestartRequired, liveKeys, readSettingsFile, settingCatalogue, writeSettingsFile, ) import ChuSQL.Web.API ( AppEnv (..), WebSession (..), Live (..), defaultLive, isIdentifier, newAppEnvAt, parseCookieHeader, sessionCookieName, webApp, ) import ChuSQL.Web.Static (contentTypeOf, safeRelative) import ChuSQL.Web.UISettings (validateUISettings) import Control.Concurrent (forkFinally, forkIO) import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Exception (IOException, bracket, throwIO, try, bracket_) import Control.Monad (when, unless) import Data.Aeson (encode, object, (.=)) import qualified Data.Aeson as A import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Lazy as BL import Data.Char (isDigit) import Data.List (isInfixOf) import Data.IORef (readIORef) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Time.Clock (getCurrentTime) import Data.Unique (hashUnique, newUnique) import qualified Data.Vector as V import Gates (gateSpec) import Network.HTTP.Client ( Manager, Request (method, requestBody, requestHeaders), RequestBody (RequestBodyLBS), Response (responseBody, responseHeaders, responseStatus), defaultManagerSettings, httpLbs, newManager, parseRequest, ) import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode) import qualified Network.Wai as Wai import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Test as WT import System.Directory ( createDirectoryIfMissing, findExecutable, getTemporaryDirectory, removeFile, removePathForcibly, ) import System.Environment (lookupEnv) import System.Exit (ExitCode (..)) import System.FilePath ((>)) import System.Info (os) import System.IO (Handle, hGetLine) import System.Process ( CreateProcess (std_err, std_out), ProcessHandle, StdStream (CreatePipe, NoStream), createProcess, proc, readProcessWithExitCode, terminateProcess, waitForProcess, ) import Test.Hspec import Test.Hspec.Wai hiding (pendingWith) -- ChuSQL Web 测试:纯函数、静态交付、门禁,以及打到真 server 的 HTTP 与 socket 用例。 -- | 夹具库名:测试默认选中的库 testDatabaseName :: Text testDatabaseName = "test" -- | 夹具管理员 adminUser :: Text adminUser = "admin" -- | 夹具管理员口令 adminPassword :: Text adminPassword = "s3cret" -- | 换成别的库(同名的旧头去掉) withDb :: Text -> [Header] -> [Header] withDb name hs = ("X-ChuSQL-Database", TE.encodeUtf8 name) : filter ((/= "X-ChuSQL-Database") . fst) hs -- | with 块的会话别名,状态放 Application type TestSession a = WaiSession Wai.Application a -- | with 的变体:把 Application 放进会话状态 withApp :: IO Wai.Application -> SpecWith (Arg (WaiExpectation Wai.Application)) -> Spec withApp mkApp = withState ((\app -> (app, app)) <$> mkApp) -- | 纯断言抬进 WaiSession check :: IO () -> TestSession () check = liftIO -- | 加上 JSON 内容类型头 jsonHeaders :: [Header] -> [Header] jsonHeaders hs = ("Content-Type", "application/json") : hs -- | 拼出会话 Cookie 头 cookieHeader :: Text -> Header cookieHeader token = ("Cookie", TE.encodeUtf8 (sessionCookieName <> "=" <> token)) -- | 发独立会话的 WAI 请求,不共享 cookie jar requestAs :: Method -> BS.ByteString -> [Header] -> BL.ByteString -> TestSession WT.SResponse requestAs verb path hs payload = do app <- getState liftIO $ WT.runSession send app where -- | 清空 cookie jar 后发出请求 send = do WT.modifyClientCookies (const Map.empty) WT.srequest $ WT.SRequest ( WT.setPath ( WT.defaultRequest { Wai.requestMethod = verb , Wai.requestHeaders = hs , Wai.requestBodyLength = Wai.KnownLength (fromIntegral (BL.length payload)) } ) path ) payload -- | 带头发 GET 请求 getAs :: [Header] -> BS.ByteString -> TestSession WT.SResponse getAs hs path = requestAs methodGet path hs BL.empty -- | 带 JSON 头发 POST 请求 postAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse postAs hs path = requestAs methodPost path (jsonHeaders hs) -- | 带 JSON 头发 PUT 请求 putAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse putAs hs path = requestAs "PUT" path (jsonHeaders hs) -- | 带 JSON 头发 PATCH 请求 patchAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse patchAs hs path = requestAs "PATCH" path (jsonHeaders hs) -- | 带头发 DELETE 请求 deleteAs :: [Header] -> BS.ByteString -> TestSession WT.SResponse deleteAs hs path = requestAs "DELETE" path hs BL.empty -- | 用账号口令登录 loginAs :: Text -> Text -> TestSession WT.SResponse loginAs user password = requestAs methodPost "/api/login" (jsonHeaders []) (encode (object ["user" .= user, "password" .= password])) -- | 登录并返回会话 Cookie 请求头 signIn :: Text -> Text -> TestSession [Header] signIn user password = do res <- loginAs user password expectStatusWith "sign-in" res 204 case sessionCookieOf res of Just token -> pure [cookieHeader token] Nothing -> do check (expectationFailure "sign-in succeeded without a session cookie") pure [] -- | 以管理员登录并取请求头 adminHeaders :: TestSession [Header] adminHeaders = signIn adminUser adminPassword -- | 已登录的普通账号 ordinaryHeaders :: Text -> Text -> TestSession [Header] ordinaryHeaders user password = do res <- loginAs user password expectStatusWith "ordinary sign-in" res 204 case sessionCookieOf res of Just token -> pure [cookieHeader token] Nothing -> do check (expectationFailure "ordinary sign-in without a session cookie") pure [] -- | 建库,已存在就算就位 ensureDatabase :: [Header] -> Text -> TestSession () ensureDatabase hs name = do listed <- getAs hs "/api/databases" expectStatusWith "list databases" listed 200 let names = map asText (items (jsonBody listed)) unless (name `elem` names) $ do created <- postAs hs "/api/databases" (encode (object ["name" .= name])) expectStatusWith ("create database " <> T.unpack name) created 200 -- | 建表:已存在就当就位 ensureTable :: [Header] -> Text -> BL.ByteString -> TestSession () ensureTable hs name body = do listed <- getAs (withDb testDatabaseName hs) "/api/tables" expectStatusWith "list tables" listed 200 let names = map (asText . at "table") (items (jsonBody listed)) unless (name `elem` names) $ do created <- postAs (withDb testDatabaseName hs) "/api/tables" body expectStatusWith ("create table " <> T.unpack name) created 200 -- | 建账号:重名(409)也算就位 ensureAccount :: [Header] -> Text -> Text -> TestSession () ensureAccount hs user password = do created <- postAs (withDb "system" hs) "/api/tables/__system_users/rows" (encode (object ["values" .= object ["user" .= user, "password" .= password]])) check (statusCode (WT.simpleStatus created) `shouldSatisfy` (`elem` [200, 409])) -- | 列定义 column :: Text -> Text -> A.Value column name ty = object ["name" .= name, "type" .= ty] -- | 响应体重解成 JSON jsonBody :: WT.SResponse -> A.Value jsonBody res = fromMaybe A.Null (A.decode (WT.simpleBody res)) -- | 取字段 field :: Text -> A.Value -> Maybe A.Value field name (A.Object o) = KM.lookup (K.fromText name) o field _ _ = Nothing -- | 取字段(没有给 Null) at :: Text -> A.Value -> A.Value at name value = fromMaybe A.Null (field name value) -- | 当数组看 items :: A.Value -> [A.Value] items (A.Array xs) = V.toList xs items _ = [] -- | 当文本看 asText :: A.Value -> Text asText (A.String t) = t asText _ = "" -- | 当整数看 asInt :: A.Value -> Int asInt (A.Number n) = round n asInt _ = -1 -- | 当布尔看 asBool :: A.Value -> Bool asBool (A.Bool b) = b asBool _ = False -- | 第 i 个元素,越界给 Nothing nth :: Int -> [a] -> Maybe a nth i xs = case drop i xs of (x : _) -> Just x [] -> Nothing -- | 数组字段的长度 arrayLen :: A.Value -> Int arrayLen = length . items -- | 第一行(位置化数组) firstRow :: WT.SResponse -> [A.Value] firstRow res = case items (at "rows" (jsonBody res)) of (A.Array xs : _) -> V.toList xs _ -> [] -- | items 数组里按键找一项(找不到给 Null) headEntry :: Text -> [A.Value] -> A.Value headEntry key entries = case [e | e <- entries, asText (at "key" e) == key] of (e : _) -> e [] -> A.Null -- | 响应头 responseHeader :: Header -> WT.SResponse -> Maybe BS.ByteString responseHeader (name, _) res = lookup name (WT.simpleHeaders res) -- | 取 Set-Cookie 里的会话令牌 sessionCookieOf :: WT.SResponse -> Maybe Text sessionCookieOf res = do raw <- lookup "Set-Cookie" (WT.simpleHeaders res) parseCookieHeader sessionCookieName (TE.decodeUtf8 raw) -- | 状态码断言,失败时把响应体和状态码一起报出来(定位用) expectStatusWith :: String -> WT.SResponse -> Int -> TestSession () expectStatusWith label res code = check $ if statusCode (WT.simpleStatus res) == code then pure () else expectationFailure ( label ++ ": expected " ++ show code ++ " but got " ++ show (statusCode (WT.simpleStatus res)) ++ " body=" ++ show (WT.simpleBody res) ) -- | Either 判成 isRightE :: Either a b -> Bool isRightE = either (const False) (const True) -- | Either 判错 isLeftE :: Either a b -> Bool isLeftE = either (const True) (const False) -- | 临时文件 tempFilePath :: String -> IO FilePath tempFilePath name = do tmp <- getTemporaryDirectory uniq <- uniqueSuffix pure (tmp > ("chusql-web-test-" ++ name ++ "-" ++ uniq ++ ".toml")) -- | 非重复后缀 uniqueSuffix :: IO String uniqueSuffix = do show . hashUnique <$> newUnique -- | 文件不存在也算成功 removeIfExists :: FilePath -> IO () removeIfExists path = do _ <- try (removeFile path) :: IO (Either IOException ()) pure () -- | 临时静态目录,内容固定便于断言 makeStaticDir :: IO FilePath makeStaticDir = do tmp <- getTemporaryDirectory uniq <- uniqueSuffix let dir = tmp > ("chusql-web-static-" ++ uniq) createDirectoryIfMissing True dir BS.writeFile (dir > "index.html") "
ChuSQL Console" BS.writeFile (dir > "app.js") "// test bundle\n" BS.writeFile (dir > "style.css") "body{}\n" BS.writeFile (dir > "secret.token") "do-not-serve\n" pure dir -- | 只做静态交付的环境,不连 server staticOnlyEnv :: FilePath -> IO AppEnv staticOnlyEnv staticDir = do limiter <- newRateLimiter getCurrentTime 5 (5 * 60) settings <- tempFilePath "static" removeIfExists settings newAppEnvAt settings "127.0.0.1" 1 limiter staticDir -- 真 server 世界 -------------------------------------------------------------- -- | 一个真 server 进程及其端口、配置和数据目录 data LiveWorld = LiveWorld { lwProcess :: ProcessHandle , lwPort :: Int , lwConfig :: FilePath , lwDataDir :: FilePath } -- | 起 server 进程并从横幅读回端口 startLiveWorld :: String -> IO LiveWorld startLiveWorld label = do bin <- locateServerExe tmp <- getTemporaryDirectory uniq <- uniqueSuffix let root = tmp > ("chusql-web-live-" ++ label ++ "-" ++ uniq) dataDir = root > "data" config = root > "settings.toml" createDirectoryIfMissing True dataDir writeFile config (serverConfigText dataDir) seedSystemCatalog config (_, Just out, _, process) <- createProcess (proc bin ["--config", config, "--user", T.unpack adminUser]){std_out = CreatePipe, std_err = NoStream} port <- readListeningPort out _ <- forkIO (drainHandle out) setAdminPassword port pure (LiveWorld process port config dataDir) -- | 首启的管理员还没有口令:先用空口令登录,再按业务流程设一个 setAdminPassword :: Int -> IO () setAdminPassword port = do opened <- connectClient "127.0.0.1" port case opened of Left err -> fail ("fixture server refused the connection: " ++ T.unpack err) Right client -> do session <- newSession client signed <- authenticateSession session adminUser "" case signed of Left err -> fail ("fixture administrator cannot sign in: " ++ T.unpack err) Right () -> do changed <- runStatement session ("ALTER USER " <> adminUser <> " IDENTIFIED BY '" <> adminPassword <> "'") case changed of Left err -> fail ("fixture administrator keeps no password: " ++ T.unpack err) Right _ -> pure () closeClient client -- | 生成夹具 server 的配置文本 serverConfigText :: FilePath -> String serverConfigText dataDir = unlines [ "[web]" , "password-min-length = 8" , "password-classes = 2" , "" , "[server]" , "listen_host = \"127.0.0.1\"" , "port = 0" , "" , "[storage]" , "data_dir = \"" ++ slashed dataDir ++ "\"" ] where -- | 反斜杠换成正斜杠 slashed = map (\c -> if c == '\\' then '/' else c) -- | 逐行读 stdout 直到读出端口 readListeningPort :: Handle -> IO Int readListeningPort handle = do line <- hGetLine handle case portFromBanner line of Just port -> pure port Nothing -> readListeningPort handle -- | 取一行里最后一串数字 portFromBanner :: String -> Maybe Int portFromBanner line | not ("listening:" `isInfixOf` line) = Nothing | otherwise = case span isDigit (dropWhile (not . isDigit) (reverse line)) of ([], _) -> Nothing (digits, _) -> Just (read (reverse digits)) -- | 抽干 server 的 stdout drainHandle :: Handle -> IO () drainHandle handle = do result <- try (hGetLine handle) :: IO (Either IOException String) case result of Left _ -> pure () Right _ -> drainHandle handle -- | 按平台补上 .exe 后缀 binaryName :: String -> String binaryName stem = if os == "mingw32" then stem ++ ".exe" else stem -- | 在 PATH 上查找 chusql-server locateServerExe :: IO FilePath locateServerExe = do found <- findExecutable (binaryName "chusql-server") case found of Just path -> pure path Nothing -> fail "chusql-server was not found on PATH: run `stack build` first" -- | 全新的数据目录还没有系统目录:先跑引导程序建表并补一个免密管理员, -- server 才肯启动(未引导的目录会被它拒绝)。 seedSystemCatalog :: FilePath -> IO () seedSystemCatalog config = do bootstrap <- locateBootstrapExe (code, out, errOut) <- readProcessWithExitCode bootstrap ["--config", config, "--passwordless", "--user", T.unpack adminUser] "" case code of ExitSuccess -> pure () _ -> fail ("the fixture bootstrap program failed (exit " ++ show code ++ "): " ++ out ++ errOut) -- | 找引导程序:先环境变量,再 PATH locateBootstrapExe :: IO FilePath locateBootstrapExe = do fromEnv <- lookupEnv "CHUSQL_BOOTSTRAP_EXE" case fromEnv of Just path -> pure path Nothing -> do found <- findExecutable (binaryName "csql-bootstrap") case found of Just path -> pure path Nothing -> fail "csql-bootstrap was not found on PATH: run `stack build` first" -- | 构造指向真 server 的 Web 环境 liveEnv :: LiveWorld -> FilePath -> Int -> IO AppEnv liveEnv world staticDir attempts = do limiter <- newRateLimiter getCurrentTime attempts (5 * 60) uiPath <- tempFilePath "ui-settings" removeIfExists uiPath base <- newAppEnvAt (lwConfig world) "127.0.0.1" (lwPort world) limiter staticDir pure base{aeUISettingsFile = uiPath} -- | 收摊:杀进程、清数据目录 stopServer :: LiveWorld -> IO () stopServer world = do terminateProcess (lwProcess world) _ <- try (waitForProcess (lwProcess world)) :: IO (Either IOException ExitCode) _ <- try (removePathForcibly (lwDataDir world)) :: IO (Either IOException ()) pure () -- 纯函数 --------------------------------------------------------------------- -- | 纯函数用例 unitSpec :: Spec unitSpec = describe "pure helpers" $ do it "rejects malformed runtime values before producing write SQL" $ do case resolveType builtinTypes (listType intType) of Left err -> expectationFailure err Right descriptor -> Actions.insertRowSql "t" [("xs", Model.VRuntime (descriptorId descriptor) (RList [RString "wrong"]))] `shouldSatisfy` either (const True) (const False) it "escapes nested runtime string literals" $ do case resolveType builtinTypes (listType stringType) of Left err -> expectationFailure err Right descriptor -> Actions.sqlLiteral (Model.VRuntime (descriptorId descriptor) (RList [RString "a'b"])) `shouldBe` Right "LIST