2026-09-25 17:41:51 +08:00
|
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
|
|
|
|
|
|
|
|
module Main (main) where
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Interface.RateLimit (newRateLimiter)
|
|
|
|
|
|
import ChuSQL.Interface.Settings (
|
2026-09-25 17:41:51 +08:00
|
|
|
|
applySettings,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
defaultOf,
|
|
|
|
|
|
isLockedSetting,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
liveKeys,
|
|
|
|
|
|
readSettingsFile,
|
|
|
|
|
|
settingCatalogue,
|
|
|
|
|
|
writeSettingsFile,
|
|
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Web.API (
|
|
|
|
|
|
AppEnv (..),
|
|
|
|
|
|
Live (..),
|
|
|
|
|
|
defaultLive,
|
|
|
|
|
|
isIdentifier,
|
|
|
|
|
|
newAppEnvAt,
|
|
|
|
|
|
parseCookieHeader,
|
|
|
|
|
|
sessionCookieName,
|
|
|
|
|
|
webApp,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Web.Static (contentTypeOf, safeRelative)
|
|
|
|
|
|
import ChuSQL.Web.UISettings (validateUISettings)
|
|
|
|
|
|
import Control.Concurrent (forkIO)
|
|
|
|
|
|
import Control.Exception (IOException, bracket, try)
|
|
|
|
|
|
import Control.Monad (when)
|
|
|
|
|
|
import Data.Aeson (encode, object, (.=))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Data.Char (isDigit)
|
|
|
|
|
|
import Data.List (isInfixOf)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Data.Time.Clock (getCurrentTime)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import Data.Unique (hashUnique, newUnique)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Vector as V
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Gates (gateSpec)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import Network.HTTP.Client (
|
|
|
|
|
|
Manager,
|
|
|
|
|
|
Request (method, requestBody, requestHeaders),
|
2026-10-02 14:53:02 +08:00
|
|
|
|
RequestBody (RequestBodyLBS),
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Response (responseBody, responseHeaders, responseStatus),
|
|
|
|
|
|
defaultManagerSettings,
|
|
|
|
|
|
httpLbs,
|
|
|
|
|
|
newManager,
|
|
|
|
|
|
parseRequest,
|
|
|
|
|
|
)
|
|
|
|
|
|
import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import qualified Network.Wai as Wai
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
|
|
|
|
import qualified Network.Wai.Test as WT
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import System.Directory (
|
|
|
|
|
|
createDirectoryIfMissing,
|
|
|
|
|
|
findExecutable,
|
|
|
|
|
|
getTemporaryDirectory,
|
|
|
|
|
|
removeFile,
|
|
|
|
|
|
removePathForcibly,
|
|
|
|
|
|
)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import System.Exit (ExitCode (..))
|
|
|
|
|
|
import System.FilePath ((</>))
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import System.Info (os)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import System.IO (Handle, hGetLine)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import System.Process (
|
2026-10-02 14:53:02 +08:00
|
|
|
|
CreateProcess (std_err, std_out),
|
2026-09-25 17:41:51 +08:00
|
|
|
|
ProcessHandle,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
StdStream (CreatePipe, NoStream),
|
2026-09-25 17:41:51 +08:00
|
|
|
|
createProcess,
|
|
|
|
|
|
proc,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
terminateProcess,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
waitForProcess,
|
|
|
|
|
|
)
|
|
|
|
|
|
import Test.Hspec
|
|
|
|
|
|
import Test.Hspec.Wai hiding (pendingWith)
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- ChuSQL Web 测试:纯函数、静态交付、门禁,以及打到真 server 的 HTTP 与 socket 用例。
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 夹具库名:测试默认选中的库
|
|
|
|
|
|
testDatabaseName :: Text
|
2026-10-01 20:48:38 +08:00
|
|
|
|
testDatabaseName = "test"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 夹具管理员
|
|
|
|
|
|
adminUser :: Text
|
|
|
|
|
|
adminUser = "admin"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 夹具管理员口令
|
|
|
|
|
|
adminPassword :: Text
|
|
|
|
|
|
adminPassword = "s3cret"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 换成别的库(同名的旧头去掉)
|
|
|
|
|
|
withDb :: Text -> [Header] -> [Header]
|
|
|
|
|
|
withDb name hs = ("X-ChuSQL-Database", TE.encodeUtf8 name) : filter ((/= "X-ChuSQL-Database") . fst) hs
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | with 块的会话别名,状态放 Application
|
|
|
|
|
|
type TestSession a = WaiSession Wai.Application a
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | with 的变体:把 Application 放进会话状态
|
|
|
|
|
|
withApp :: IO Wai.Application -> SpecWith (Arg (WaiExpectation Wai.Application)) -> Spec
|
|
|
|
|
|
withApp mkApp = withState ((\app -> (app, app)) <$> mkApp)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 纯断言抬进 WaiSession
|
|
|
|
|
|
check :: IO () -> TestSession ()
|
|
|
|
|
|
check = liftIO
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 加上 JSON 内容类型头
|
|
|
|
|
|
jsonHeaders :: [Header] -> [Header]
|
|
|
|
|
|
jsonHeaders hs = ("Content-Type", "application/json") : hs
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 拼出会话 Cookie 头
|
|
|
|
|
|
cookieHeader :: Text -> Header
|
|
|
|
|
|
cookieHeader token = ("Cookie", TE.encodeUtf8 (sessionCookieName <> "=" <> token))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 发独立会话的 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
|
2026-09-25 17:41:51 +08:00
|
|
|
|
where
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 清空 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
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 带头发 GET 请求
|
|
|
|
|
|
getAs :: [Header] -> BS.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
getAs hs path = requestAs methodGet path hs BL.empty
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 带 JSON 头发 POST 请求
|
|
|
|
|
|
postAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
postAs hs path payload = requestAs methodPost path (jsonHeaders hs) payload
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 带 JSON 头发 PUT 请求
|
|
|
|
|
|
putAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
putAs hs path payload = requestAs "PUT" path (jsonHeaders hs) payload
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 带 JSON 头发 PATCH 请求
|
|
|
|
|
|
patchAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
patchAs hs path payload = requestAs "PATCH" path (jsonHeaders hs) payload
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 带头发 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))
|
|
|
|
|
|
when (name `notElem` 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))
|
|
|
|
|
|
when (name `notElem` 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/__chusql_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
|
2026-09-25 17:41:51 +08:00
|
|
|
|
jsonBody :: WT.SResponse -> A.Value
|
2026-10-02 14:53:02 +08:00
|
|
|
|
jsonBody res = fromMaybe A.Null (A.decode (WT.simpleBody res))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 取字段
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 第 i 个元素,越界给 Nothing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
nth :: Int -> [a] -> Maybe a
|
|
|
|
|
|
nth i xs = case drop i xs of
|
|
|
|
|
|
(x : _) -> Just x
|
|
|
|
|
|
[] -> Nothing
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 数组字段的长度
|
|
|
|
|
|
arrayLen :: A.Value -> Int
|
|
|
|
|
|
arrayLen = length . items
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 第一行(位置化数组)
|
|
|
|
|
|
firstRow :: WT.SResponse -> [A.Value]
|
|
|
|
|
|
firstRow res = case items (at "rows" (jsonBody res)) of
|
|
|
|
|
|
(A.Array xs : _) -> V.toList xs
|
|
|
|
|
|
_ -> []
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | items 数组里按键找一项(找不到给 Null)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
headEntry :: Text -> [A.Value] -> A.Value
|
|
|
|
|
|
headEntry key entries = case [e | e <- entries, asText (at "key" e) == key] of
|
|
|
|
|
|
(e : _) -> e
|
|
|
|
|
|
[] -> A.Null
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 响应头
|
|
|
|
|
|
responseHeader :: Header -> WT.SResponse -> Maybe BS.ByteString
|
|
|
|
|
|
responseHeader (name, _) res = lookup name (WT.simpleHeaders res)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 取 Set-Cookie 里的会话令牌
|
|
|
|
|
|
sessionCookieOf :: WT.SResponse -> Maybe Text
|
|
|
|
|
|
sessionCookieOf res = do
|
|
|
|
|
|
raw <- lookup "Set-Cookie" (WT.simpleHeaders res)
|
|
|
|
|
|
parseCookieHeader sessionCookieName (TE.decodeUtf8 raw)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 状态码断言,失败时把响应体和状态码一起报出来(定位用)
|
|
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | Either 判成
|
|
|
|
|
|
isRightE :: Either a b -> Bool
|
|
|
|
|
|
isRightE = either (const False) (const True)
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 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"))
|
|
|
|
|
|
|
|
|
|
|
|
-- | 非重复后缀
|
2026-10-01 20:48:38 +08:00
|
|
|
|
uniqueSuffix :: IO String
|
|
|
|
|
|
uniqueSuffix = do
|
|
|
|
|
|
u <- newUnique
|
|
|
|
|
|
pure (show (hashUnique u))
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 文件不存在也算成功
|
|
|
|
|
|
removeIfExists :: FilePath -> IO ()
|
|
|
|
|
|
removeIfExists path = do
|
|
|
|
|
|
_ <- try (removeFile path) :: IO (Either IOException ())
|
|
|
|
|
|
pure ()
|
|
|
|
|
|
|
|
|
|
|
|
-- | 临时静态目录,内容固定便于断言
|
|
|
|
|
|
makeStaticDir :: IO FilePath
|
|
|
|
|
|
makeStaticDir = do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
tmp <- getTemporaryDirectory
|
2026-10-02 14:53:02 +08:00
|
|
|
|
uniq <- uniqueSuffix
|
|
|
|
|
|
let dir = tmp </> ("chusql-web-static-" ++ uniq)
|
|
|
|
|
|
createDirectoryIfMissing True dir
|
|
|
|
|
|
BS.writeFile (dir </> "index.html") "<!DOCTYPE html><html lang=\"zh-CN\"><head></head><body>ChuSQL Console</body></html>"
|
|
|
|
|
|
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 </> "chusql.toml"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
createDirectoryIfMissing True dataDir
|
2026-10-02 14:53:02 +08:00
|
|
|
|
writeFile config (serverConfigText dataDir)
|
|
|
|
|
|
(_, Just out, _, process) <-
|
|
|
|
|
|
createProcess (proc bin ["--config", config]){std_out = CreatePipe, std_err = NoStream}
|
|
|
|
|
|
port <- readListeningPort out
|
|
|
|
|
|
_ <- forkIO (drainHandle out)
|
|
|
|
|
|
pure (LiveWorld process port config dataDir)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 生成夹具 server 的配置文本
|
|
|
|
|
|
serverConfigText :: FilePath -> String
|
|
|
|
|
|
serverConfigText dataDir =
|
|
|
|
|
|
unlines
|
|
|
|
|
|
[ "[web]"
|
|
|
|
|
|
, "user = \"" ++ T.unpack adminUser ++ "\""
|
|
|
|
|
|
, "password = \"" ++ T.unpack adminPassword ++ "\""
|
|
|
|
|
|
, "password-min-length = 8"
|
|
|
|
|
|
, "password-classes = 2"
|
|
|
|
|
|
, ""
|
|
|
|
|
|
, "[server]"
|
|
|
|
|
|
, "host = \"127.0.0.1\""
|
|
|
|
|
|
, "port = 0"
|
|
|
|
|
|
, ""
|
|
|
|
|
|
, "[storage]"
|
|
|
|
|
|
, "data_dir = \"" ++ slashed dataDir ++ "\""
|
|
|
|
|
|
]
|
|
|
|
|
|
where
|
|
|
|
|
|
-- | 反斜杠换成正斜杠
|
|
|
|
|
|
slashed = map (\c -> if c == '\\' then '/' else c)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 逐行读 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")
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case found of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Just path -> pure path
|
|
|
|
|
|
Nothing -> fail "chusql-server was not found on PATH: run `stack build` first"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 构造指向真 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 "parses the session cookie out of a Cookie header" $ do
|
|
|
|
|
|
parseCookieHeader sessionCookieName "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123"
|
|
|
|
|
|
parseCookieHeader sessionCookieName "other=1" `shouldBe` Nothing
|
|
|
|
|
|
parseCookieHeader sessionCookieName "" `shouldBe` Nothing
|
|
|
|
|
|
it "accepts plain identifiers and rejects everything else" $ do
|
|
|
|
|
|
isIdentifier "users" `shouldBe` True
|
|
|
|
|
|
isIdentifier "_9" `shouldBe` True
|
|
|
|
|
|
isIdentifier "" `shouldBe` False
|
|
|
|
|
|
isIdentifier "1users" `shouldBe` False
|
|
|
|
|
|
isIdentifier "user-name" `shouldBe` False
|
|
|
|
|
|
isIdentifier (T.replicate 65 "a") `shouldBe` False
|
|
|
|
|
|
it "keeps static delivery inside the asset whitelist" $ do
|
|
|
|
|
|
safeRelative "index.html" `shouldBe` Just "index.html"
|
|
|
|
|
|
safeRelative "app.js" `shouldBe` Just "app.js"
|
|
|
|
|
|
safeRelative "../secret.html" `shouldBe` Nothing
|
|
|
|
|
|
safeRelative "asset/app.js" `shouldBe` Nothing
|
|
|
|
|
|
safeRelative "secret.token" `shouldBe` Nothing
|
|
|
|
|
|
safeRelative "index.htmlx" `shouldBe` Nothing
|
|
|
|
|
|
it "maps extensions to content types" $ do
|
|
|
|
|
|
contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "app.js" `shouldBe` "text/javascript; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "x.woff2" `shouldBe` "font/woff2"
|
|
|
|
|
|
contentTypeOf "x.bin" `shouldBe` "application/octet-stream"
|
|
|
|
|
|
it "ships the documented live defaults" $ do
|
|
|
|
|
|
lvMaxRows defaultLive `shouldBe` 1000
|
|
|
|
|
|
lvPageSize defaultLive `shouldBe` 25
|
|
|
|
|
|
lvBodyLimit defaultLive `shouldBe` 65536
|
|
|
|
|
|
it "round-trips settings through a real TOML file" $ do
|
|
|
|
|
|
path <- tempFilePath "settings"
|
|
|
|
|
|
removeIfExists path
|
|
|
|
|
|
writeSettingsFile path (Map.fromList [("rows-per-page", "50"), ("user", "admin"), ("password", "s3cret")])
|
|
|
|
|
|
>>= (`shouldSatisfy` isRightE)
|
|
|
|
|
|
values <- readSettingsFile path
|
|
|
|
|
|
Map.lookup "rows-per-page" values `shouldBe` Just "50"
|
|
|
|
|
|
Map.lookup "user" values `shouldBe` Just "admin"
|
|
|
|
|
|
it "rejects unknown settings and out-of-range limits" $ do
|
|
|
|
|
|
applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE
|
|
|
|
|
|
applySettings Map.empty (Map.fromList [("password-min-length", "3")]) `shouldSatisfy` isLeftE
|
|
|
|
|
|
applySettings Map.empty (Map.fromList [("password-min-length", "12")]) `shouldSatisfy` isRightE
|
|
|
|
|
|
it "marks root credentials as locked and lists the live keys" $ do
|
|
|
|
|
|
isLockedSetting "password" `shouldBe` True
|
|
|
|
|
|
isLockedSetting "rows-per-page" `shouldBe` False
|
|
|
|
|
|
("rows-per-page" `elem` liveKeys) `shouldBe` True
|
|
|
|
|
|
length settingCatalogue `shouldSatisfy` (> 20)
|
|
|
|
|
|
defaultOf "rows-per-page" `shouldBe` "25"
|
|
|
|
|
|
it "validates IDE settings" $ do
|
|
|
|
|
|
validateUISettings (object ["sqlFontSize" .= (13 :: Int), "minimap" .= False])
|
|
|
|
|
|
`shouldSatisfy` isRightE
|
|
|
|
|
|
validateUISettings (object ["nope" .= (1 :: Int)]) `shouldSatisfy` isLeftE
|
|
|
|
|
|
validateUISettings (A.String "nope") `shouldSatisfy` isLeftE
|
|
|
|
|
|
|
|
|
|
|
|
-- 静态交付 -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 静态交付用例
|
|
|
|
|
|
staticSpec :: FilePath -> Spec
|
|
|
|
|
|
staticSpec staticDir = describe "static delivery" $ do
|
|
|
|
|
|
env <- runIO (staticOnlyEnv staticDir)
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "serves the console page with a content-security-policy" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/" [] BL.empty
|
|
|
|
|
|
expectStatusWith "index" res 200
|
|
|
|
|
|
check (responseHeader ("Content-Type", "") res `shouldBe` Just "text/html; charset=utf-8")
|
|
|
|
|
|
check (responseHeader ("Content-Security-Policy", "") res `shouldSatisfy` (/= Nothing))
|
|
|
|
|
|
check (BSC.isInfixOf "chusql-style-nonce" (BL.toStrict (WT.simpleBody res)) `shouldBe` True)
|
|
|
|
|
|
it "serves an asset inside the whitelist" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/static/app.js" [] BL.empty
|
|
|
|
|
|
expectStatusWith "app.js" res 200
|
|
|
|
|
|
check (responseHeader ("Content-Type", "") res `shouldBe` Just "text/javascript; charset=utf-8")
|
|
|
|
|
|
it "refuses assets outside the whitelist" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/static/secret.token" [] BL.empty
|
|
|
|
|
|
expectStatusWith "secret.token" res 404
|
|
|
|
|
|
it "answers unknown routes with a JSON 404" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/api/nope" [] BL.empty
|
|
|
|
|
|
expectStatusWith "unknown route" res 404
|
|
|
|
|
|
check (asText (at "error" (jsonBody res)) `shouldBe` "not_found")
|
|
|
|
|
|
it "reports the process is alive without touching the storage link" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/api/health" [] BL.empty
|
|
|
|
|
|
expectStatusWith "health" res 200
|
|
|
|
|
|
check (at "status" (jsonBody res) `shouldBe` A.String "ok")
|
|
|
|
|
|
|
|
|
|
|
|
-- 会话 -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 登录与会话用例
|
|
|
|
|
|
sessionSpec :: AppEnv -> Spec
|
|
|
|
|
|
sessionSpec env = describe "sign-in and sessions (web -> TCP -> server)" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "refuses protected endpoints without a cookie" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/api/databases" [] BL.empty
|
|
|
|
|
|
expectStatusWith "no cookie" res 401
|
|
|
|
|
|
check (asText (at "error" (jsonBody res)) `shouldBe` "unauthorized")
|
|
|
|
|
|
it "refuses a wrong password" $ do
|
|
|
|
|
|
res <- loginAs adminUser "not-the-password"
|
|
|
|
|
|
expectStatusWith "wrong password" res 401
|
|
|
|
|
|
it "signs in, reports the session policy from the server, and signs out" $ do
|
|
|
|
|
|
res <- loginAs adminUser adminPassword
|
|
|
|
|
|
expectStatusWith "sign-in" res 204
|
|
|
|
|
|
token <- case sessionCookieOf res of
|
|
|
|
|
|
Just t -> pure t
|
|
|
|
|
|
Nothing -> do
|
|
|
|
|
|
check (expectationFailure "no session cookie after sign-in")
|
|
|
|
|
|
pure ""
|
|
|
|
|
|
me <- getAs [cookieHeader token] "/api/session"
|
|
|
|
|
|
expectStatusWith "session" me 200
|
|
|
|
|
|
check (at "user" (jsonBody me) `shouldBe` A.String adminUser)
|
|
|
|
|
|
check (at "administrator" (jsonBody me) `shouldBe` A.Bool True)
|
|
|
|
|
|
check (asInt (at "minLength" (at "policy" (jsonBody me))) `shouldSatisfy` (>= 8))
|
|
|
|
|
|
out <- requestAs "POST" "/api/logout" [cookieHeader token] BL.empty
|
|
|
|
|
|
expectStatusWith "sign-out" out 204
|
|
|
|
|
|
afterSignOut <- getAs [cookieHeader token] "/api/session"
|
|
|
|
|
|
expectStatusWith "session after sign-out" afterSignOut 401
|
|
|
|
|
|
it "reports the storage link through a fresh connection" $ do
|
|
|
|
|
|
res <- requestAs methodGet "/api/status" [] BL.empty
|
|
|
|
|
|
expectStatusWith "status" res 200
|
|
|
|
|
|
check (at "storage" (jsonBody res) `shouldBe` A.String "up")
|
|
|
|
|
|
|
|
|
|
|
|
-- 门禁 -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 门禁用例
|
|
|
|
|
|
gatingSpec :: AppEnv -> Spec
|
|
|
|
|
|
gatingSpec env = describe "gate keeping" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "rejects an illegal database name in the request header" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <- requestAs methodGet "/api/tables" (withDb "bad-name" hs) BL.empty
|
|
|
|
|
|
expectStatusWith "illegal database" res 400
|
|
|
|
|
|
it "rejects the account table outside the system database" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
res <- getAs (withDb testDatabaseName hs) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
expectStatusWith "account table outside system" res 400
|
|
|
|
|
|
it "rejects role management without a cookie" $ do
|
|
|
|
|
|
res <- getAs [] "/api/roles"
|
|
|
|
|
|
expectStatusWith "roles without a cookie" res 401
|
|
|
|
|
|
|
|
|
|
|
|
-- 库、表、行 -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 库、表、行用例
|
|
|
|
|
|
catalogSpec :: AppEnv -> Spec
|
|
|
|
|
|
catalogSpec env = describe "databases, tables and rows" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "creates a database, lists it and drops it" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
created <- postAs hs "/api/databases" (encode (object ["name" .= ("shop" :: Text)]))
|
|
|
|
|
|
expectStatusWith "create shop" created 200
|
|
|
|
|
|
listed <- getAs hs "/api/databases"
|
|
|
|
|
|
expectStatusWith "list databases" listed 200
|
|
|
|
|
|
check (("shop" `elem` map asText (items (jsonBody listed))) `shouldBe` True)
|
|
|
|
|
|
dropped <- deleteAs hs "/api/databases/shop"
|
|
|
|
|
|
expectStatusWith "drop shop" dropped 200
|
|
|
|
|
|
it "refuses to drop the system database" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <- deleteAs hs "/api/databases/system"
|
|
|
|
|
|
expectStatusWith "drop system" res 400
|
|
|
|
|
|
it "creates a table, inserts a row, reads it back and updates it" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
ensureTable
|
|
|
|
|
|
hs
|
|
|
|
|
|
"people"
|
|
|
|
|
|
( encode
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "name" .= ("people" :: Text)
|
|
|
|
|
|
, "columns"
|
|
|
|
|
|
.= [ column "id" "int"
|
|
|
|
|
|
, column "name" "varchar(64)"
|
|
|
|
|
|
, column "note" "varchar(64)"
|
|
|
|
|
|
]
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
inserted <-
|
|
|
|
|
|
postAs
|
|
|
|
|
|
(withDb testDatabaseName hs)
|
|
|
|
|
|
"/api/tables/people/rows"
|
|
|
|
|
|
( encode
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "values"
|
|
|
|
|
|
.= object
|
|
|
|
|
|
[ "id" .= (1 :: Int)
|
|
|
|
|
|
, "name" .= ("alice" :: Text)
|
|
|
|
|
|
, "note" .= ("first" :: Text)
|
|
|
|
|
|
]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
]
|
2026-10-02 14:53:02 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
expectStatusWith "insert row" inserted 200
|
|
|
|
|
|
rows <- getAs (withDb testDatabaseName hs) "/api/tables/people/rows?limit=10"
|
|
|
|
|
|
expectStatusWith "read rows" rows 200
|
|
|
|
|
|
check (asText (at "table" (jsonBody rows)) `shouldBe` "people")
|
|
|
|
|
|
check (arrayLen (at "columns" (jsonBody rows)) `shouldBe` 3)
|
|
|
|
|
|
check (arrayLen (at "rows" (jsonBody rows)) `shouldBe` 1)
|
|
|
|
|
|
check (asInt (at "total" (jsonBody rows)) `shouldBe` 1)
|
|
|
|
|
|
check (asInt (maybe A.Null id (nth 0 (firstRow rows))) `shouldBe` 1)
|
|
|
|
|
|
check (asText (maybe A.Null id (nth 1 (firstRow rows))) `shouldBe` "alice")
|
|
|
|
|
|
updated <-
|
|
|
|
|
|
patchAs
|
|
|
|
|
|
(withDb testDatabaseName hs)
|
|
|
|
|
|
"/api/tables/people/rows/1"
|
|
|
|
|
|
(encode (object ["values" .= object ["note" .= ("changed" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "update row" updated 200
|
|
|
|
|
|
again <- getAs (withDb testDatabaseName hs) "/api/tables/people/rows?limit=10"
|
|
|
|
|
|
check (asText (maybe A.Null id (nth 2 (firstRow again))) `shouldBe` "changed")
|
|
|
|
|
|
it "deletes the row and then the table" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
ensureTable hs "scratch" (encode (object ["name" .= ("scratch" :: Text), "columns" .= [column "id" "int", column "label" "varchar(64)"]]))
|
|
|
|
|
|
inserted <- postAs (withDb testDatabaseName hs) "/api/tables/scratch/rows" (encode (object ["values" .= object ["id" .= (2 :: Int), "label" .= ("x" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "insert scratch row" inserted 200
|
|
|
|
|
|
deleted <- deleteAs (withDb testDatabaseName hs) "/api/tables/scratch/rows/2"
|
|
|
|
|
|
expectStatusWith "delete row" deleted 200
|
|
|
|
|
|
rows <- getAs (withDb testDatabaseName hs) "/api/tables/scratch/rows"
|
|
|
|
|
|
check (asInt (at "total" (jsonBody rows)) `shouldBe` 0)
|
|
|
|
|
|
dropped <- deleteAs (withDb testDatabaseName hs) "/api/tables/scratch"
|
|
|
|
|
|
expectStatusWith "drop table" dropped 200
|
|
|
|
|
|
it "manages indexes and columns of a table" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
ensureTable hs "metrics" (encode (object ["name" .= ("metrics" :: Text), "columns" .= [column "id" "int", column "score" "int", column "tag" "varchar(64)"]]))
|
|
|
|
|
|
made <- postAs (withDb testDatabaseName hs) "/api/tables/metrics/indexes" (encode (object ["column" .= ("score" :: Text)]))
|
|
|
|
|
|
expectStatusWith "create index" made 200
|
|
|
|
|
|
builtIn <- deleteAs (withDb testDatabaseName hs) "/api/tables/metrics/indexes/id"
|
|
|
|
|
|
expectStatusWith "built-in index is refused" builtIn 400
|
|
|
|
|
|
dropped <- deleteAs (withDb testDatabaseName hs) "/api/tables/metrics/indexes/score"
|
|
|
|
|
|
expectStatusWith "drop index" dropped 200
|
|
|
|
|
|
idColumn <- deleteAs (withDb testDatabaseName hs) "/api/tables/metrics/columns/id"
|
|
|
|
|
|
expectStatusWith "built-in column is refused" idColumn 400
|
|
|
|
|
|
columnDropped <- deleteAs (withDb testDatabaseName hs) "/api/tables/metrics/columns/tag"
|
|
|
|
|
|
expectStatusWith "drop column" columnDropped 200
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- 设置与口令策略 -------------------------------------------------------------
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 设置与口令策略用例
|
|
|
|
|
|
settingsSpec :: AppEnv -> Spec
|
|
|
|
|
|
settingsSpec env = describe "settings and password policy" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "shows the catalogue to the administrator" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <- getAs hs "/api/settings"
|
|
|
|
|
|
expectStatusWith "settings" res 200
|
|
|
|
|
|
let entries = items (at "items" (jsonBody res))
|
|
|
|
|
|
check (length entries `shouldSatisfy` (> 20))
|
|
|
|
|
|
check (asText (at "value" (headEntry "rows-per-page" entries)) `shouldBe` "25")
|
|
|
|
|
|
check (at "locked" (headEntry "password" entries) `shouldBe` A.Bool True)
|
|
|
|
|
|
it "writes a setting, makes the server reload the policy, and reports it applied" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <-
|
|
|
|
|
|
putAs
|
|
|
|
|
|
hs
|
|
|
|
|
|
"/api/settings"
|
|
|
|
|
|
( encode
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "values"
|
|
|
|
|
|
.= object
|
|
|
|
|
|
[ "password-min-length" .= ("16" :: Text)
|
|
|
|
|
|
, "password-classes" .= ("3" :: Text)
|
|
|
|
|
|
]
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
expectStatusWith "write settings" res 200
|
|
|
|
|
|
let applied = map asText (items (at "applied" (jsonBody res)))
|
|
|
|
|
|
check (("password-min-length" `elem` applied) `shouldBe` True)
|
|
|
|
|
|
me <- getAs hs "/api/session"
|
|
|
|
|
|
check (asInt (at "minLength" (at "policy" (jsonBody me))) `shouldBe` 16)
|
|
|
|
|
|
values <- liftIO (readSettingsFile (aeSettingsFile env))
|
|
|
|
|
|
check (Map.lookup "password-min-length" values `shouldBe` Just "16")
|
|
|
|
|
|
check (Map.lookup "user" values `shouldBe` Just adminUser)
|
|
|
|
|
|
it "refuses unknown settings and the locked root credentials" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
unknown <- putAs hs "/api/settings" (encode (object ["values" .= object ["nope" .= ("1" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "unknown setting" unknown 400
|
|
|
|
|
|
locked <- putAs hs "/api/settings" (encode (object ["values" .= object ["password" .= ("hacked" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "locked credential" locked 403
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- 账号表 ---------------------------------------------------------------------
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 账号表用例
|
|
|
|
|
|
accountSpec :: AppEnv -> Spec
|
|
|
|
|
|
accountSpec env = describe "the account table (system database, admin only)" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "lists the account table in the system database" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <- getAs (withDb "system" hs) "/api/tables"
|
|
|
|
|
|
expectStatusWith "system tables" res 200
|
|
|
|
|
|
check (("__chusql_users" `elem` map (asText . at "table") (items (jsonBody res))) `shouldBe` True)
|
|
|
|
|
|
it "creates an account, changes its password, signs in as it and drops it" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureAccount hs "alice" "alice-Secret-1234"
|
|
|
|
|
|
duplicate <-
|
|
|
|
|
|
postAs
|
|
|
|
|
|
(withDb "system" hs)
|
|
|
|
|
|
"/api/tables/__chusql_users/rows"
|
|
|
|
|
|
(encode (object ["values" .= object ["user" .= ("alice" :: Text), "password" .= ("alice-Secret-9999" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "duplicate account" duplicate 409
|
|
|
|
|
|
alice <- ordinaryHeaders "alice" "alice-Secret-1234"
|
|
|
|
|
|
forbidden <- getAs (withDb "system" alice) "/api/tables"
|
|
|
|
|
|
expectStatusWith "ordinary account cannot enter system" forbidden 403
|
|
|
|
|
|
visible <- getAs alice "/api/databases"
|
|
|
|
|
|
expectStatusWith "ordinary account lists databases" visible 200
|
|
|
|
|
|
check (("system" `elem` map asText (items (jsonBody visible))) `shouldBe` False)
|
|
|
|
|
|
changed <-
|
|
|
|
|
|
patchAs
|
|
|
|
|
|
(withDb "system" hs)
|
|
|
|
|
|
"/api/tables/__chusql_users/rows/alice"
|
|
|
|
|
|
(encode (object ["values" .= object ["password" .= ("alice-Secret-5678" :: Text)]]))
|
|
|
|
|
|
expectStatusWith "change password" changed 200
|
|
|
|
|
|
_ <- ordinaryHeaders "alice" "alice-Secret-5678"
|
|
|
|
|
|
dropped <- deleteAs (withDb "system" hs) "/api/tables/__chusql_users/rows/alice"
|
|
|
|
|
|
expectStatusWith "drop account" dropped 200
|
|
|
|
|
|
it "refuses account writes without a cookie" $ do
|
|
|
|
|
|
res <- getAs (withDb "system" []) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
expectStatusWith "account rows without a cookie" res 401
|
|
|
|
|
|
|
|
|
|
|
|
-- 角色 -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 角色与授权用例
|
|
|
|
|
|
roleSpec :: AppEnv -> Spec
|
|
|
|
|
|
roleSpec env = describe "roles and grants" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "creates a role, grants a privilege, adds a member and drops it" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
ensureAccount hs "bob" "bob-Secret-123456"
|
|
|
|
|
|
created <- postAs hs "/api/roles" (encode (object ["name" .= ("analyst" :: Text)]))
|
|
|
|
|
|
expectStatusWith "create role" created 200
|
|
|
|
|
|
granted <-
|
|
|
|
|
|
postAs
|
|
|
|
|
|
hs
|
|
|
|
|
|
"/api/roles/analyst/grants"
|
|
|
|
|
|
(encode (object ["object" .= testDatabaseName, "privileges" .= (["select"] :: [Text])]))
|
|
|
|
|
|
expectStatusWith "grant privilege" granted 200
|
|
|
|
|
|
member <- postAs hs "/api/roles/analyst/members" (encode (object ["user" .= ("bob" :: Text)]))
|
|
|
|
|
|
expectStatusWith "add member" member 200
|
|
|
|
|
|
listed <- getAs hs "/api/roles"
|
|
|
|
|
|
expectStatusWith "list roles" listed 200
|
|
|
|
|
|
check (("analyst" `elem` map (asText . at "name") (items (jsonBody listed))) `shouldBe` True)
|
|
|
|
|
|
removed <- deleteAs hs "/api/roles/analyst/members/bob"
|
|
|
|
|
|
expectStatusWith "remove member" removed 200
|
|
|
|
|
|
revoked <-
|
|
|
|
|
|
deleteAs
|
|
|
|
|
|
hs
|
|
|
|
|
|
"/api/roles/analyst/grants"
|
|
|
|
|
|
expectStatusWith "revoke without a body" revoked 400
|
|
|
|
|
|
dropped <- deleteAs hs "/api/roles/analyst"
|
|
|
|
|
|
expectStatusWith "drop role" dropped 200
|
|
|
|
|
|
|
|
|
|
|
|
-- SQL 控制台 -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | SQL 控制台用例
|
|
|
|
|
|
querySpec :: AppEnv -> Spec
|
|
|
|
|
|
querySpec env = describe "the SQL console" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "runs DDL and DML and returns positional rows" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
made <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("CREATE TABLE console_probe (id int, label varchar(64))" :: Text)]))
|
|
|
|
|
|
expectStatusWith "create table by SQL" made 200
|
|
|
|
|
|
inserted <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("INSERT INTO console_probe (id, label) VALUES (7, 'seven')" :: Text)]))
|
|
|
|
|
|
expectStatusWith "insert by SQL" inserted 200
|
|
|
|
|
|
res <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("SELECT * FROM console_probe" :: Text)]))
|
|
|
|
|
|
expectStatusWith "select by SQL" res 200
|
|
|
|
|
|
check (asInt (at "rowCount" (jsonBody res)) `shouldBe` 1)
|
|
|
|
|
|
check (arrayLen (at "columns" (jsonBody res)) `shouldBe` 2)
|
|
|
|
|
|
check (asText (maybe A.Null id (nth 1 (firstRow res))) `shouldBe` "seven")
|
|
|
|
|
|
it "refuses an empty statement" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
res <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("" :: Text)]))
|
|
|
|
|
|
expectStatusWith "empty sql" res 400
|
|
|
|
|
|
it "refuses an oversized body" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
res <- postAs (withDb testDatabaseName hs) "/api/query" (BL.replicate 70000 32)
|
|
|
|
|
|
expectStatusWith "oversized body" res 413
|
|
|
|
|
|
it "reports a syntax error as a bad request" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
ensureDatabase hs testDatabaseName
|
|
|
|
|
|
res <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("SELEC nonsense" :: Text)]))
|
|
|
|
|
|
expectStatusWith "syntax error" res 400
|
|
|
|
|
|
|
|
|
|
|
|
-- IDE 设置 -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | IDE 设置用例
|
|
|
|
|
|
uiSettingsSpec :: AppEnv -> Spec
|
|
|
|
|
|
uiSettingsSpec env = describe "IDE settings" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "round-trips a valid payload" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
initialUi <- getAs hs "/api/ui-settings"
|
|
|
|
|
|
expectStatusWith "read IDE settings" initialUi 200
|
|
|
|
|
|
saved <- putAs hs "/api/ui-settings" (encode (object ["sqlFontSize" .= (13 :: Int), "minimap" .= False]))
|
|
|
|
|
|
expectStatusWith "write IDE settings" saved 204
|
|
|
|
|
|
updatedUi <- getAs hs "/api/ui-settings"
|
|
|
|
|
|
check (at "sqlFontSize" (jsonBody updatedUi) `shouldBe` A.Number 13)
|
|
|
|
|
|
it "refuses an unknown key" $ do
|
|
|
|
|
|
hs <- adminHeaders
|
|
|
|
|
|
bad <- putAs hs "/api/ui-settings" (encode (object ["nope" .= (1 :: Int)]))
|
|
|
|
|
|
expectStatusWith "unknown IDE setting" bad 400
|
|
|
|
|
|
it "needs a session" $ do
|
|
|
|
|
|
res <- getAs [] "/api/ui-settings"
|
|
|
|
|
|
expectStatusWith "IDE settings without a cookie" res 401
|
|
|
|
|
|
|
|
|
|
|
|
-- 限流 -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 登录限流用例
|
|
|
|
|
|
rateLimitSpec :: AppEnv -> Spec
|
|
|
|
|
|
rateLimitSpec env = describe "sign-in rate limiting" $ do
|
|
|
|
|
|
withApp (webApp env) $ do
|
|
|
|
|
|
it "locks the account after the configured failures" $ do
|
|
|
|
|
|
first <- loginAs adminUser "wrong-one"
|
|
|
|
|
|
expectStatusWith "first failure" first 401
|
|
|
|
|
|
second <- loginAs adminUser "wrong-two"
|
|
|
|
|
|
expectStatusWith "locked out" second 429
|
|
|
|
|
|
third <- loginAs adminUser adminPassword
|
|
|
|
|
|
expectStatusWith "still locked out" third 429
|
|
|
|
|
|
|
|
|
|
|
|
-- 真 socket 端到端 -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
-- | 真 socket 端到端用例
|
|
|
|
|
|
e2eSpec :: AppEnv -> Spec
|
|
|
|
|
|
e2eSpec env = describe "end to end (HTTP socket -> web -> TCP -> server -> storage)" $ do
|
|
|
|
|
|
it "signs in over a real socket and lists databases" $ do
|
|
|
|
|
|
Warp.testWithApplication (webApp env) $ \port -> do
|
|
|
|
|
|
manager <- newManager defaultManagerSettings
|
|
|
|
|
|
login <- httpJson manager port methodPost "/api/login" [] (encode (object ["user" .= adminUser, "password" .= adminPassword]))
|
|
|
|
|
|
statusCode (responseStatus login) `shouldBe` 204
|
|
|
|
|
|
let cookie = case cookieOf login of
|
|
|
|
|
|
Just token -> [cookieHeader token]
|
|
|
|
|
|
Nothing -> []
|
|
|
|
|
|
listed <- httpJson manager port methodGet "/api/databases" cookie BL.empty
|
|
|
|
|
|
statusCode (responseStatus listed) `shouldBe` 200
|
|
|
|
|
|
let body = fromMaybe A.Null (A.decode (responseBody listed))
|
|
|
|
|
|
("system" `elem` map asText (items body)) `shouldBe` True
|
|
|
|
|
|
|
|
|
|
|
|
-- | 走真 HTTP 客户端打出去的一个请求
|
|
|
|
|
|
httpJson :: Manager -> Int -> Method -> BS.ByteString -> [Header] -> BL.ByteString -> IO (Response BL.ByteString)
|
|
|
|
|
|
httpJson manager port httpMethod path headers payload = do
|
|
|
|
|
|
baseRequest <- parseRequest ("http://127.0.0.1:" ++ show port ++ BSC.unpack path)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
httpLbs
|
2026-10-02 14:53:02 +08:00
|
|
|
|
baseRequest
|
2026-09-25 17:41:51 +08:00
|
|
|
|
{ method = httpMethod
|
2026-10-02 14:53:02 +08:00
|
|
|
|
, requestHeaders = jsonHeaders headers
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, requestBody = RequestBodyLBS payload
|
|
|
|
|
|
}
|
2026-10-02 14:53:02 +08:00
|
|
|
|
manager
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 从响应里取会话令牌
|
|
|
|
|
|
cookieOf :: Response BL.ByteString -> Maybe Text
|
|
|
|
|
|
cookieOf res = do
|
|
|
|
|
|
raw <- lookup "Set-Cookie" (responseHeaders res)
|
|
|
|
|
|
parseCookieHeader sessionCookieName (TE.decodeUtf8 raw)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 入口:起夹具并跑全部用例
|
|
|
|
|
|
main :: IO ()
|
|
|
|
|
|
main = do
|
|
|
|
|
|
staticDir <- makeStaticDir
|
|
|
|
|
|
-- server 用 bracket 兜底收摊:断言抛异常、用例被过滤器全跳过,都不会留下孤儿进程占着 exe
|
|
|
|
|
|
bracket (startLiveWorld "main") stopServer $ \world ->
|
|
|
|
|
|
bracket (startLiveWorld "limited") stopServer $ \limitedWorld -> do
|
|
|
|
|
|
env <- liveEnv world staticDir 5
|
|
|
|
|
|
limitedEnv <- liveEnv limitedWorld staticDir 1
|
|
|
|
|
|
hspec (spec staticDir env limitedEnv)
|
|
|
|
|
|
|
|
|
|
|
|
-- | 汇总挂载全部用例组
|
|
|
|
|
|
spec :: FilePath -> AppEnv -> AppEnv -> Spec
|
|
|
|
|
|
spec staticDir env limitedEnv = do
|
|
|
|
|
|
unitSpec
|
|
|
|
|
|
staticSpec staticDir
|
|
|
|
|
|
gateSpec
|
|
|
|
|
|
sessionSpec env
|
|
|
|
|
|
gatingSpec env
|
|
|
|
|
|
catalogSpec env
|
|
|
|
|
|
settingsSpec env
|
|
|
|
|
|
accountSpec env
|
|
|
|
|
|
roleSpec env
|
|
|
|
|
|
querySpec env
|
|
|
|
|
|
uiSettingsSpec env
|
|
|
|
|
|
e2eSpec env
|
|
|
|
|
|
rateLimitSpec limitedEnv
|