1770 lines
92 KiB
Haskell
1770 lines
92 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
||
|
||
module Main (main) where
|
||
|
||
import ChuSQL.Model (Column (..), Database, Table (..), Value (..))
|
||
import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..))
|
||
import ChuSQL.Syntax.AST (Expr (..), FromClause (..), Statement (..))
|
||
import ChuSQL.Web.Actions (
|
||
ColumnSpec (..),
|
||
CreateTableSpec (..),
|
||
coerceValue,
|
||
columnTypeOf,
|
||
createIndexSql,
|
||
createTableSql,
|
||
deleteRowSql,
|
||
dropIndexSql,
|
||
dropTableSql,
|
||
insertRowSql,
|
||
insertRowsSql,
|
||
isIdentifier,
|
||
selectRowsSql,
|
||
sqlLiteral,
|
||
updateRowSql,
|
||
)
|
||
import ChuSQL.Web.Api (
|
||
AppEnv (..),
|
||
Live (..),
|
||
defaultLive,
|
||
newAppEnv,
|
||
parseCookieHeader,
|
||
readLive,
|
||
renderRow,
|
||
setLive,
|
||
webApp,
|
||
)
|
||
import ChuSQL.Web.Auth (
|
||
Credential (..),
|
||
SessionPolicy (..),
|
||
createSession,
|
||
defaultSessionPolicy,
|
||
deleteOtherSessions,
|
||
deleteSession,
|
||
hashLooksValid,
|
||
hashPasswordWith,
|
||
lookupSession,
|
||
newSessionStore,
|
||
sessionPolicy,
|
||
sessionToken,
|
||
setSessionPolicy,
|
||
verifyPassword,
|
||
)
|
||
import ChuSQL.Web.Backend (columnsFromStatement, ipcBackend, memoryBackend, tableInfoOf)
|
||
import ChuSQL.Web.Config (
|
||
WebConfig (..),
|
||
defaultPassword,
|
||
defaultUser,
|
||
defaultWebConfig,
|
||
loadWebConfig,
|
||
resolveCredential,
|
||
usingDefaultCredentials,
|
||
)
|
||
import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit)
|
||
import ChuSQL.Web.Settings (
|
||
SettingItem (..),
|
||
applySettings,
|
||
defaultOf,
|
||
effectiveSettings,
|
||
findItem,
|
||
isRestartRequired,
|
||
isRootOnly,
|
||
liveKeys,
|
||
readSettingsFile,
|
||
resolveSettingsFile,
|
||
settingCatalogue,
|
||
writeSettingsFile,
|
||
)
|
||
import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative)
|
||
import ChuSQL.Web.StorageProcess (StorageProcess (..), startStorageProcess, stopStorageProcess)
|
||
import ChuSQL.Web.UiSettings (
|
||
readUiSettings,
|
||
resolveUiSettingsFile,
|
||
uiSettingsFileCandidates,
|
||
validateUiSettings,
|
||
writeUiSettings,
|
||
)
|
||
import Control.Concurrent.MVar (newMVar)
|
||
import Control.Exception (IOException, bracket, finally, try)
|
||
import Data.Aeson (decode)
|
||
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.IORef (newIORef, readIORef, writeIORef)
|
||
import Data.List (isInfixOf, isSuffixOf, nub, sort)
|
||
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.Calendar (fromGregorian)
|
||
import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime)
|
||
import qualified Data.Vector as V
|
||
import Network.HTTP.Client (
|
||
Manager,
|
||
Request (method, requestBody, requestHeaders),
|
||
RequestBody (..),
|
||
Response (responseBody, responseHeaders, responseStatus),
|
||
defaultManagerSettings,
|
||
httpLbs,
|
||
newManager,
|
||
parseRequest,
|
||
)
|
||
import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode)
|
||
import Network.Wai (Application)
|
||
import qualified Network.Wai.Handler.Warp as Warp
|
||
import qualified Network.Wai.Test as WT
|
||
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory)
|
||
import qualified System.Directory
|
||
import System.Environment (lookupEnv, setEnv, unsetEnv)
|
||
import System.Exit (ExitCode (..))
|
||
import System.FilePath ((</>))
|
||
import System.IO (Handle)
|
||
import System.Process (
|
||
CreateProcess (cwd, std_err, std_out),
|
||
ProcessHandle,
|
||
StdStream (..),
|
||
createProcess,
|
||
proc,
|
||
waitForProcess,
|
||
)
|
||
import Test.Hspec
|
||
import Test.Hspec.Wai hiding (pendingWith)
|
||
|
||
-- ChuSQL Web 测试,覆盖三层:
|
||
-- 纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。
|
||
|
||
-- | users 5 行 + orders 1 行
|
||
testDb :: Database
|
||
testDb =
|
||
[ ( "users"
|
||
, Table
|
||
"users"
|
||
[("id", TInt), ("name", TStr), ("age", TInt)]
|
||
[ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (20 + i))]
|
||
| i <- [1 .. 5]
|
||
]
|
||
)
|
||
, ( "orders"
|
||
, Table
|
||
"orders"
|
||
[("id", TInt), ("user_id", TInt)]
|
||
[[("id", VInt 1), ("user_id", VInt 1)]]
|
||
)
|
||
]
|
||
|
||
-- | 测试账号:迭代次数压低(跑得快),口令 `s3cret`
|
||
testCredential :: Credential
|
||
testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret")
|
||
|
||
-- | 可控时钟的起点
|
||
epoch :: UTCTime
|
||
epoch = UTCTime (fromGregorian 2026 1 1) 0
|
||
|
||
-- | 默认会话策略
|
||
policy :: SessionPolicy
|
||
policy = SessionPolicy (8 * 3600) (24 * 3600)
|
||
|
||
-- | 造一条 `SELECT *`
|
||
selectStar :: FromClause -> Statement
|
||
selectStar from = Select ["*"] from Nothing [] Nothing
|
||
|
||
-- | 造一条显式列名的 SELECT
|
||
selectThese :: [String] -> FromClause -> Statement
|
||
selectThese cols from = Select cols from Nothing [] Nothing
|
||
|
||
-- | with 块里的会话类型别名
|
||
type TestSession a = WaiSession () a
|
||
|
||
-- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录
|
||
freshWorld :: FilePath -> IO AppEnv
|
||
freshWorld staticDir = do
|
||
db <- newMVar testDb
|
||
clock <- newIORef epoch
|
||
sessions <- newSessionStore (readIORef clock) policy
|
||
limiter <- newRateLimiter (readIORef clock) 5 (5 * 60)
|
||
env <- newAppEnv (memoryBackend db) sessions testCredential limiter staticDir
|
||
path <- tempSettingsPath "default"
|
||
removeIfExists path
|
||
uiPath <- tempUiSettingsPath "default"
|
||
removeIfExists uiPath
|
||
pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath, aeEffective = Map.empty}
|
||
|
||
-- | 临时设置文件(每个用例组一个名字,互不干扰)
|
||
tempSettingsPath :: String -> IO FilePath
|
||
tempSettingsPath name = do
|
||
tmp <- getTemporaryDirectory
|
||
pure (tmp </> ("chusql-web-test-" ++ name ++ "-settings.json"))
|
||
|
||
-- | 临时 IDE 设置文件(同样按用例组取名)
|
||
tempUiSettingsPath :: String -> IO FilePath
|
||
tempUiSettingsPath name = do
|
||
tmp <- getTemporaryDirectory
|
||
pure (tmp </> ("chusql-web-test-" ++ name ++ "-ui-settings.json"))
|
||
|
||
-- | 文件不存在也算成功
|
||
removeIfExists :: FilePath -> IO ()
|
||
removeIfExists path = do
|
||
there <- doesFileExist path
|
||
if there then removeFileSafe path else pure ()
|
||
where
|
||
removeFileSafe p = do
|
||
_ <- try (System.Directory.removeFile p) :: IO (Either IOException ())
|
||
pure ()
|
||
|
||
-- | 带自己的设置文件的用例组
|
||
freshWorldAt :: FilePath -> String -> IO AppEnv
|
||
freshWorldAt staticDir name = do
|
||
env <- freshWorld staticDir
|
||
path <- tempSettingsPath name
|
||
removeIfExists path
|
||
uiPath <- tempUiSettingsPath name
|
||
removeIfExists uiPath
|
||
pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath}
|
||
|
||
-- | 临时静态目录,内容固定便于断言
|
||
makeStaticDir :: IO FilePath
|
||
makeStaticDir = do
|
||
tmp <- getTemporaryDirectory
|
||
let dir = tmp </> "chusql-web-static-test"
|
||
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
|
||
|
||
-- | 一个用例组一套环境
|
||
withWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec
|
||
withWorld staticDir body = do
|
||
appEnv <- runIO (freshWorld staticDir)
|
||
with (webApp appEnv) (body appEnv)
|
||
|
||
-- | 同上,但带着自己的设置文件(改设置/改口令的用例不互相串)
|
||
withWorldAt :: FilePath -> String -> (AppEnv -> SpecWith ((), Application)) -> Spec
|
||
withWorldAt staticDir name body = do
|
||
appEnv <- runIO (freshWorldAt staticDir name)
|
||
with (webApp appEnv) (body appEnv)
|
||
|
||
-- | 响应体解成 JSON
|
||
jsonBody :: WT.SResponse -> A.Value
|
||
jsonBody res = fromMaybe A.Null (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
|
||
|
||
-- | 第 i 个元素(越界给 Nothing,不用 `!!`)
|
||
nth :: Int -> [a] -> Maybe a
|
||
nth i xs = case drop i xs of
|
||
(x : _) -> Just x
|
||
[] -> Nothing
|
||
|
||
-- | 取 Set-Cookie 里的会话令牌
|
||
sessionCookieOf :: WT.SResponse -> Maybe Text
|
||
sessionCookieOf res = do
|
||
raw <- lookup "Set-Cookie" (WT.simpleHeaders res)
|
||
parseCookieHeader "chusql_session" (TE.decodeUtf8 raw)
|
||
|
||
-- | 会话令牌变请求头
|
||
cookieHeaders :: WT.SResponse -> [Header]
|
||
cookieHeaders res = case sessionCookieOf res of
|
||
Nothing -> []
|
||
Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token))]
|
||
|
||
-- | 带 JSON 体的请求
|
||
jsonRequest :: Method -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
||
jsonRequest httpMethod urlPath payload =
|
||
request httpMethod urlPath [("Content-Type", "application/json")] payload
|
||
|
||
-- | 登录请求体
|
||
loginBody :: Text -> Text -> BL.ByteString
|
||
loginBody user password =
|
||
BL.fromStrict . TE.encodeUtf8 $
|
||
T.concat ["{\"user\":\"", user, "\",\"password\":\"", password, "\"}"]
|
||
|
||
-- | 登录
|
||
loginAs :: Text -> Text -> TestSession WT.SResponse
|
||
loginAs user password = jsonRequest methodPost "/api/login" (loginBody user password)
|
||
|
||
-- | 带请求头的 GET
|
||
getWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse
|
||
getWith headers path = request methodGet path headers ""
|
||
|
||
-- | 带请求头的 JSON POST
|
||
postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
||
postWith headers path payload =
|
||
request methodPost path (("Content-Type", "application/json") : headers) payload
|
||
|
||
-- | 带请求头的 JSON PUT(改设置用的是 PUT)
|
||
putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
||
putWith headers path payload =
|
||
request "PUT" path (("Content-Type", "application/json") : headers) payload
|
||
|
||
-- | 在 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
|
||
|
||
-- | 纯断言抬进 WaiSession
|
||
check :: IO () -> TestSession ()
|
||
check = liftIO
|
||
|
||
-- | 已经拿到响应对象时的状态码断言
|
||
hasStatus :: WT.SResponse -> Int -> IO ()
|
||
hasStatus res code = statusCode (WT.simpleStatus res) `shouldBe` code
|
||
|
||
-- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉)
|
||
withEnv :: [(String, String)] -> IO a -> IO a
|
||
withEnv kvs act = do
|
||
saved <- mapM (\(k, _) -> fmap ((,) k) (lookupEnv k)) kvs
|
||
mapM_ (uncurry setEnv) kvs
|
||
act `finally` mapM_ restore saved
|
||
where
|
||
restore (k, mv) = maybe (unsetEnv k) (setEnv k) mv
|
||
|
||
-- | 按 UTF-8 读文件(避免解码错)
|
||
readUtf8 :: FilePath -> IO Text
|
||
readUtf8 path = TE.decodeUtf8 <$> BS.readFile path
|
||
|
||
-- | Either 判空
|
||
isLeftE :: Either a b -> Bool
|
||
isLeftE (Left _) = True
|
||
isLeftE (Right _) = False
|
||
|
||
-- | Either 判成
|
||
isRightE :: Either a b -> Bool
|
||
isRightE = either (const False) (const True)
|
||
|
||
-- | 响应体里第一行(表格数据)
|
||
firstRow :: WT.SResponse -> [A.Value]
|
||
firstRow res = case items (at "rows" (jsonBody res)) of
|
||
(r : _) -> case r of
|
||
A.Array xs -> V.toList xs
|
||
_ -> []
|
||
[] -> []
|
||
|
||
-- | 数组字段的长度
|
||
arrayLen :: A.Value -> Int
|
||
arrayLen = length . items
|
||
|
||
main :: IO ()
|
||
main = hspec spec
|
||
|
||
spec :: Spec
|
||
spec = do
|
||
staticDir <- runIO makeStaticDir
|
||
unitSpec
|
||
actionsSpec
|
||
staticSpec staticDir
|
||
frontendBundleSpec
|
||
authSpec staticDir
|
||
catalogSpec staticDir
|
||
limitsSpec staticDir
|
||
oneClickSpec staticDir
|
||
sqlSpec staticDir
|
||
settingsSpec staticDir
|
||
uiSettingsSpec staticDir
|
||
sortSpec staticDir
|
||
securitySpec staticDir
|
||
e2eSpec staticDir
|
||
|
||
unitSpec :: Spec
|
||
unitSpec = do
|
||
describe "Password hashing" $ do
|
||
it "accepts the correct password" $
|
||
verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "s3cret" `shouldBe` True
|
||
it "rejects a wrong password" $
|
||
verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "wrong" `shouldBe` False
|
||
it "treats a broken encoded hash as mismatch instead of throwing" $
|
||
verifyPassword "garbage" "s3cret" `shouldBe` False
|
||
it "same password with different salts yields different hashes" $
|
||
hashPasswordWith 1000 (BS.replicate 16 1) "s3cret"
|
||
`shouldNotBe` hashPasswordWith 1000 (BS.replicate 16 2) "s3cret"
|
||
it "encoded hash has four parts: algo$iterations$salt$hash" $
|
||
T.splitOn "$" (hashPasswordWith 1000 (BS.replicate 16 3) "s3cret")
|
||
`shouldSatisfy` ((== 4) . length)
|
||
|
||
describe "Sessions" $ do
|
||
it "token is 64 hex chars and differs between calls" $ do
|
||
a <- sessionToken
|
||
b <- sessionToken
|
||
T.length a `shouldBe` 64
|
||
T.all (`elem` ("0123456789abcdef" :: String)) a `shouldBe` True
|
||
a `shouldNotBe` b
|
||
it "idle timeout drops the session" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) policy
|
||
token <- createSession store "admin"
|
||
fresh <- lookupSession store token
|
||
fresh `shouldBe` Just "admin"
|
||
writeIORef clock (addUTCTime (9 * 3600) epoch)
|
||
stale <- lookupSession store token
|
||
stale `shouldBe` Nothing
|
||
it "activity refreshes the idle timer but the absolute limit still expires" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) policy
|
||
token <- createSession store "admin"
|
||
writeIORef clock (addUTCTime (7 * 3600) epoch)
|
||
seen <- lookupSession store token
|
||
seen `shouldBe` Just "admin"
|
||
writeIORef clock (addUTCTime (13 * 3600) epoch)
|
||
seenAgain <- lookupSession store token
|
||
seenAgain `shouldBe` Just "admin"
|
||
writeIORef clock (addUTCTime (25 * 3600) epoch)
|
||
gone <- lookupSession store token
|
||
gone `shouldBe` Nothing
|
||
it "deleted session is no longer found" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) policy
|
||
token <- createSession store "admin"
|
||
deleteSession store token
|
||
found <- lookupSession store token
|
||
found `shouldBe` Nothing
|
||
it "a new expiry policy takes effect immediately (settings hot reload path)" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) (SessionPolicy (8 * 3600) (24 * 3600))
|
||
token <- createSession store "admin"
|
||
setSessionPolicy store (SessionPolicy 10 20)
|
||
now <- sessionPolicy store
|
||
spIdleSeconds now `shouldBe` 10
|
||
writeIORef clock (addUTCTime 11 epoch)
|
||
gone <- lookupSession store token
|
||
gone `shouldBe` Nothing
|
||
it "password change drops other sessions but keeps the current one" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) policy
|
||
keep <- createSession store "admin"
|
||
other <- createSession store "admin"
|
||
removed <- deleteOtherSessions store keep
|
||
kept <- lookupSession store keep
|
||
dropped <- lookupSession store other
|
||
removed `shouldBe` 1
|
||
kept `shouldBe` Just "admin"
|
||
dropped `shouldBe` Nothing
|
||
it "no other sessions means zero removed (visible in logs)" $ do
|
||
clock <- newIORef epoch
|
||
store <- newSessionStore (readIORef clock) policy
|
||
keep <- createSession store "admin"
|
||
removed <- deleteOtherSessions store keep
|
||
removed `shouldBe` 0
|
||
stillThere <- lookupSession store keep
|
||
stillThere `shouldBe` Just "admin"
|
||
|
||
describe "Login rate limiting" $ do
|
||
it "below the limit is not blocked" $ do
|
||
clock <- newIORef epoch
|
||
rl <- newRateLimiter (readIORef clock) 3 60
|
||
rateLimitRecord rl "admin"
|
||
rateLimitRecord rl "admin"
|
||
open <- rateLimitBlock rl "admin"
|
||
open `shouldBe` Nothing
|
||
it "hitting the limit blocks and returns seconds to wait" $ do
|
||
clock <- newIORef epoch
|
||
rl <- newRateLimiter (readIORef clock) 3 60
|
||
mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3]
|
||
blocked <- rateLimitBlock rl "admin"
|
||
blocked `shouldSatisfy` maybe False (> 0)
|
||
it "window expiry allows retries again" $ do
|
||
clock <- newIORef epoch
|
||
rl <- newRateLimiter (readIORef clock) 3 60
|
||
mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3]
|
||
writeIORef clock (addUTCTime 61 epoch)
|
||
open <- rateLimitBlock rl "admin"
|
||
open `shouldBe` Nothing
|
||
it "raising the threshold unlocks immediately (settings hot reload)" $ do
|
||
clock <- newIORef epoch
|
||
rl <- newRateLimiter (readIORef clock) 3 60
|
||
mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3]
|
||
blocked <- rateLimitBlock rl "admin"
|
||
blocked `shouldSatisfy` maybe False (> 0)
|
||
setRateLimit rl 10 60
|
||
unlocked <- rateLimitBlock rl "admin"
|
||
unlocked `shouldBe` Nothing
|
||
it "shortening the window allows immediately (settings hot reload)" $ do
|
||
clock <- newIORef epoch
|
||
rl <- newRateLimiter (readIORef clock) 3 3600
|
||
mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3]
|
||
setRateLimit rl 3 1
|
||
writeIORef clock (addUTCTime 2 epoch)
|
||
open <- rateLimitBlock rl "admin"
|
||
open `shouldBe` Nothing
|
||
|
||
describe "Cookie parsing" $ do
|
||
it "picks the session token out of many cookies" $
|
||
parseCookieHeader "chusql_session" "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123"
|
||
it "missing key yields Nothing" $
|
||
parseCookieHeader "chusql_session" "a=1; b=2" `shouldBe` Nothing
|
||
it "empty value does not count as a value" $
|
||
parseCookieHeader "chusql_session" "chusql_session=" `shouldBe` Nothing
|
||
|
||
describe "Password hash validation (checked before changing the password)" $ do
|
||
it "self-computed hash is valid" $
|
||
hashLooksValid (hashPasswordWith 1000 (BS.replicate 16 5) "s3cret") `shouldBe` True
|
||
it "broken hashes are invalid (bad length/algo/parts)" $ do
|
||
hashLooksValid "garbage" `shouldBe` False
|
||
hashLooksValid "pbkdf2-sha256$1000$00$00" `shouldBe` False
|
||
hashLooksValid "md5$1000$00$00" `shouldBe` False
|
||
|
||
describe "Settings catalogue (shared by the settings page and the server)" $ do
|
||
it "keys are unique and kind/default are filled" $ do
|
||
let keys = map siKey settingCatalogue
|
||
length keys `shouldBe` length (nub keys)
|
||
all (not . T.null . siKey) settingCatalogue `shouldBe` True
|
||
all (not . T.null . siLabel) settingCatalogue `shouldBe` True
|
||
all (\i -> siKind i `elem` ["text", "int", "bool", "secret"]) settingCatalogue `shouldBe` True
|
||
let machineReadable = [i | i <- settingCatalogue, siKind i `elem` ["int", "bool"]]
|
||
machineReadable `shouldSatisfy` (not . null)
|
||
all (not . T.null . siDefault) machineReadable `shouldBe` True
|
||
[siDefault i | i <- settingCatalogue, siKind i == "secret"] `shouldSatisfy` all T.null
|
||
let envs = map siEnv settingCatalogue
|
||
all (not . T.null) envs `shouldBe` True
|
||
length envs `shouldBe` length (nub envs)
|
||
it "every item has an env var and can be found by key" $ do
|
||
all (not . T.null . siEnv) settingCatalogue `shouldBe` True
|
||
map (fmap siKey . findItem . siKey) settingCatalogue `shouldBe` map (Just . siKey) settingCatalogue
|
||
it "critical settings are root-only, tuning ones are not" $ do
|
||
isRootOnly "port" `shouldBe` True
|
||
isRootOnly "host" `shouldBe` True
|
||
isRootOnly "user" `shouldBe` True
|
||
isRootOnly "password-hash" `shouldBe` True
|
||
isRootOnly "rows-per-page" `shouldBe` False
|
||
isRootOnly "body-limit" `shouldBe` False
|
||
it "live keys exist in the catalogue and are all integers" $ do
|
||
liveKeys `shouldNotSatisfy` null
|
||
map findItem liveKeys `shouldSatisfy` all (/= Nothing)
|
||
[siKind i | Just i <- map findItem liveKeys] `shouldSatisfy` all (== "int")
|
||
it "restart and live flags do not conflict: port needs a restart, page size does not" $ do
|
||
isRestartRequired "port" `shouldBe` True
|
||
isRestartRequired "static-dir" `shouldBe` True
|
||
isRestartRequired "rows-per-page" `shouldBe` False
|
||
filter isRestartRequired liveKeys `shouldBe` []
|
||
it "defaults are found, an unknown key gives an empty string" $ do
|
||
defaultOf "port" `shouldBe` "7777"
|
||
defaultOf "rows-per-page" `shouldBe` "25"
|
||
defaultOf "nope" `shouldBe` ""
|
||
it "the flattened startup config carries the real port/account" $ do
|
||
let flat = effectiveSettings defaultWebConfig
|
||
Map.lookup "port" flat `shouldBe` Just "7777"
|
||
Map.lookup "user" flat `shouldBe` Just defaultUser
|
||
Map.lookup "rows-per-page" flat `shouldBe` Just "25"
|
||
Map.member "password" flat `shouldBe` False
|
||
Map.member "password-hash" flat `shouldBe` False
|
||
it "merge settings: known keys applied, unknown rejected, empty clears" $ do
|
||
let merged = applySettings Map.empty (Map.fromList [("rows-per-page", "50"), ("port", "9000")])
|
||
fmap (Map.lookup "rows-per-page") merged `shouldBe` Right (Just "50")
|
||
applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE
|
||
fmap (Map.lookup "port") (applySettings (Map.fromList [("port", "9000")]) (Map.fromList [("port", "")]))
|
||
`shouldBe` Right (Just "")
|
||
it "type validation: ints reject letters, bools accept only known spellings" $ do
|
||
applySettings Map.empty (Map.fromList [("rows-per-page", "abc")]) `shouldSatisfy` isLeftE
|
||
applySettings Map.empty (Map.fromList [("rows-per-page", "10")]) `shouldSatisfy` isRightE
|
||
applySettings Map.empty (Map.fromList [("seed", "maybe")]) `shouldSatisfy` isLeftE
|
||
applySettings Map.empty (Map.fromList [("seed", "on")]) `shouldSatisfy` isRightE
|
||
it "write settings file: empty values are not stored and reads come back" $ do
|
||
tmp <- getTemporaryDirectory
|
||
let path = tmp </> "chusql-web-test-write-settings.json"
|
||
removeIfExists path
|
||
written <- writeSettingsFile path (Map.fromList [("port", "9000"), ("host", "")])
|
||
written `shouldBe` Right ()
|
||
stored <- readSettingsFile path
|
||
Map.lookup "port" stored `shouldBe` Just "9000"
|
||
Map.member "host" stored `shouldBe` False
|
||
it "write settings file: reports failure when the directory cannot be created" $ do
|
||
tmp <- getTemporaryDirectory
|
||
let blocker = tmp </> "chusql-web-test-blocker"
|
||
removeIfExists blocker
|
||
BS.writeFile blocker "not a directory"
|
||
failed <- writeSettingsFile (blocker </> "settings.json") (Map.fromList [("port", "1")])
|
||
failed `shouldSatisfy` isLeftE
|
||
removeIfExists blocker
|
||
it "an unreadable file counts as empty config (not a crash)" $ do
|
||
tmp <- getTemporaryDirectory
|
||
missing <- readSettingsFile (tmp </> "chusql-web-test-does-not-exist.json")
|
||
missing `shouldBe` Map.empty
|
||
it "settings file lookup: uses an existing file" $ do
|
||
tmp <- getTemporaryDirectory
|
||
let web = tmp </> "chusql-web-test-resolve"
|
||
createDirectoryIfMissing True (web </> "script")
|
||
BS.writeFile (web </> "script" </> "chusql.settings.json") "{\"port\":\"9000\"}"
|
||
System.Directory.withCurrentDirectory web $ do
|
||
found <- resolveSettingsFile
|
||
found `shouldBe` ("script" </> "chusql.settings.json")
|
||
it "settings file lookup: picks the candidate in an existing dir when none exists" $ do
|
||
tmp <- getTemporaryDirectory
|
||
let root = tmp </> "chusql-web-test-resolve-root"
|
||
let web = root </> "chusql-web"
|
||
createDirectoryIfMissing True (root </> "script")
|
||
createDirectoryIfMissing True web
|
||
System.Directory.withCurrentDirectory web $ do
|
||
found <- resolveSettingsFile
|
||
found `shouldBe` (".." </> "script" </> "chusql.settings.json")
|
||
|
||
describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do
|
||
it "no sort and no filter yields a plain SELECT" $
|
||
selectRowsSql "users" Nothing [] `shouldBe` Right "SELECT * FROM users"
|
||
it "column and direction produce ORDER BY" $ do
|
||
selectRowsSql "users" (Just ("age", True)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age ASC"
|
||
selectRowsSql "users" (Just ("age", False)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age DESC"
|
||
it "equality filters become WHERE with AND, placed before ORDER BY" $ do
|
||
selectRowsSql "users" Nothing [("name", VStr "Ann"), ("age", VInt 30)]
|
||
`shouldBe` Right "SELECT * FROM users WHERE name = 'Ann' AND age = 30"
|
||
selectRowsSql "users" (Just ("age", False)) [("name", VStr "O'Brien")]
|
||
`shouldBe` Right "SELECT * FROM users WHERE name = 'O''Brien' ORDER BY age DESC"
|
||
selectRowsSql "users" Nothing [("vip", VBool True)]
|
||
`shouldBe` Right "SELECT * FROM users WHERE vip = TRUE"
|
||
it "table and column names go through the whitelist (including filter columns)" $ do
|
||
selectRowsSql "users; DROP TABLE users" Nothing [] `shouldSatisfy` isLeftE
|
||
selectRowsSql "users" (Just ("age; DROP TABLE users", True)) [] `shouldSatisfy` isLeftE
|
||
selectRowsSql "users" Nothing [("age; DROP TABLE users", VInt 1)] `shouldSatisfy` isLeftE
|
||
|
||
describe "Identifier whitelist" $ do
|
||
it "normal table names pass" $ do
|
||
isIdentifier "users" `shouldBe` True
|
||
isIdentifier "user_1" `shouldBe` True
|
||
isIdentifier "_tmp" `shouldBe` True
|
||
it "leading digit / space / semicolon / empty string are rejected" $ do
|
||
isIdentifier "1users" `shouldBe` False
|
||
isIdentifier "user name" `shouldBe` False
|
||
isIdentifier "users;DROP TABLE users" `shouldBe` False
|
||
isIdentifier "" `shouldBe` False
|
||
it "over 64 characters is rejected" $
|
||
isIdentifier (T.replicate 65 "a") `shouldBe` False
|
||
|
||
describe "Static file name whitelist" $ do
|
||
it "normal file names pass" $ do
|
||
safeRelative "app.js" `shouldBe` Just "app.js"
|
||
safeRelative "style.css" `shouldBe` Just "style.css"
|
||
safeRelative "codicon.ttf" `shouldBe` Just "codicon.ttf"
|
||
safeRelative "editor.worker-a1.js" `shouldBe` Just "editor.worker-a1.js"
|
||
it "traversal / subdirectory / empty string are all rejected" $ do
|
||
safeRelative "../secret" `shouldBe` Nothing
|
||
safeRelative "sub/app.js" `shouldBe` Nothing
|
||
safeRelative "a\\b" `shouldBe` Nothing
|
||
safeRelative "" `shouldBe` Nothing
|
||
safeRelative "a b.js" `shouldBe` Nothing
|
||
it "disallowed extensions fail (.token / .db / no extension)" $ do
|
||
safeRelative "secret.token" `shouldBe` Nothing
|
||
safeRelative "catalog.json" `shouldBe` Nothing
|
||
safeRelative "data.db" `shouldBe` Nothing
|
||
safeRelative "README" `shouldBe` Nothing
|
||
|
||
describe "Content-Type" $ do
|
||
it "chosen by extension (case-insensitive)" $ do
|
||
contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8"
|
||
contentTypeOf "app.JS" `shouldBe` "text/javascript; charset=utf-8"
|
||
contentTypeOf "style.css" `shouldBe` "text/css; charset=utf-8"
|
||
contentTypeOf "codicon.ttf" `shouldBe` "font/ttf"
|
||
contentTypeOf "x.bin" `shouldBe` "application/octet-stream"
|
||
|
||
describe "Column inference (headers even for empty results)" $ do
|
||
it "SELECT * expands to the table columns" $
|
||
columnsFromStatement testDb (selectStar (FromTable Nothing "users"))
|
||
`shouldBe` ["id", "name", "age"]
|
||
it "aliased SELECT * prefixes the alias" $
|
||
columnsFromStatement testDb (selectStar (FromTable (Just "u") "users"))
|
||
`shouldBe` ["u.id", "u.name", "u.age"]
|
||
it "JOIN SELECT * puts the left table first" $
|
||
columnsFromStatement
|
||
testDb
|
||
(selectStar (FromJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "id") (Col "user_id"))))
|
||
`shouldBe` ["id", "name", "age", "o.id", "o.user_id"]
|
||
it "explicit column names are kept as-is" $
|
||
columnsFromStatement testDb (selectThese ["name"] (FromTable Nothing "users")) `shouldBe` ["name"]
|
||
it "write statements have no result columns" $
|
||
columnsFromStatement testDb (DropTable "users") `shouldBe` []
|
||
|
||
describe "Row rendering" $ do
|
||
it "values follow the given column order, missing columns give null" $
|
||
renderRow ["name", "id", "age"] [("id", VInt 1), ("name", VStr "a")]
|
||
`shouldBe` [A.String "a", A.Number 1, A.Null]
|
||
|
||
describe "In-memory backend catalogue" $ do
|
||
it "row count, columns and stats all match" $ do
|
||
case lookup "users" testDb of
|
||
Nothing -> expectationFailure "testDb has no users"
|
||
Just tbl -> do
|
||
let info = tableInfoOf ("users", tbl)
|
||
tiTable info `shouldBe` "users"
|
||
tiRows info `shouldBe` 5
|
||
map scName (tiColumns info) `shouldBe` ["id", "name", "age"]
|
||
tiStats info `shouldBe` [("id", 5, False), ("name", 5, False), ("age", 5, False)]
|
||
|
||
describe "Default account and credential source" $ do
|
||
it "the default account is root and uses the built-in demo password" $ do
|
||
let cfg = defaultWebConfig
|
||
defaultUser `shouldBe` "root"
|
||
wcUser cfg `shouldBe` "root"
|
||
usingDefaultCredentials cfg `shouldBe` True
|
||
cred <- resolveCredential cfg
|
||
credUser cred `shouldBe` defaultUser
|
||
verifyPassword (credEncoded cred) defaultPassword `shouldBe` True
|
||
it "a plain password wins and the default stops working" $ do
|
||
let cfg = defaultWebConfig{wcPassword = Just "s3cret"}
|
||
usingDefaultCredentials cfg `shouldBe` False
|
||
cred <- resolveCredential cfg
|
||
verifyPassword (credEncoded cred) "s3cret" `shouldBe` True
|
||
verifyPassword (credEncoded cred) defaultPassword `shouldBe` False
|
||
it "a provided hash is used directly (not re-hashed)" $ do
|
||
let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash"
|
||
cred <- resolveCredential defaultWebConfig{wcPasswordHash = Just encoded}
|
||
credEncoded cred `shouldBe` encoded
|
||
it "the account name can be changed" $ do
|
||
cred <- resolveCredential defaultWebConfig{wcUser = "tester"}
|
||
credUser cred `shouldBe` "tester"
|
||
|
||
describe "Config: built-in defaults" $ do
|
||
it "port defaults to 7777, account defaults to admin/chusql" $ do
|
||
wcPort defaultWebConfig `shouldBe` 7777
|
||
wcHost defaultWebConfig `shouldBe` "127.0.0.1"
|
||
wcUser defaultWebConfig `shouldBe` defaultUser
|
||
wcCookieSecure defaultWebConfig `shouldBe` False
|
||
it "session / rate limit / paging / row limits all have explicit defaults" $ do
|
||
wcSessionIdle defaultWebConfig `shouldBe` 8 * 3600
|
||
wcSessionMax defaultWebConfig `shouldBe` 24 * 3600
|
||
wcLoginMaxAttempts defaultWebConfig `shouldBe` 5
|
||
wcLoginWindow defaultWebConfig `shouldBe` 5 * 60
|
||
wcPageSize defaultWebConfig `shouldBe` 25
|
||
wcMaxPageSize defaultWebConfig `shouldBe` 500
|
||
wcMaxRows defaultWebConfig `shouldBe` 1000
|
||
wcMaxSqlLength defaultWebConfig `shouldBe` 20000
|
||
wcBodyLimit defaultWebConfig `shouldBe` 65536
|
||
|
||
describe "Config: environment variables override defaults" $ do
|
||
it "port / host / static dir / account" $ do
|
||
withEnv
|
||
[ ("CHUSQL_WEB_PORT", "8123")
|
||
, ("CHUSQL_WEB_HOST", "0.0.0.0")
|
||
, ("CHUSQL_WEB_STATIC", "mysite")
|
||
, ("CHUSQL_WEB_USER", "tester")
|
||
]
|
||
$ do
|
||
cfg <- loadWebConfig
|
||
wcPort cfg `shouldBe` 8123
|
||
wcHost cfg `shouldBe` "0.0.0.0"
|
||
wcStaticDir cfg `shouldBe` "mysite"
|
||
wcUser cfg `shouldBe` "tester"
|
||
it "session / rate limit / paging / rows / body limit" $ do
|
||
withEnv
|
||
[ ("CHUSQL_WEB_SESSION_IDLE", "60")
|
||
, ("CHUSQL_WEB_SESSION_MAX", "120")
|
||
, ("CHUSQL_WEB_LOGIN_MAX_ATTEMPTS", "3")
|
||
, ("CHUSQL_WEB_LOGIN_WINDOW", "30")
|
||
, ("CHUSQL_WEB_PAGE_SIZE", "7")
|
||
, ("CHUSQL_WEB_MAX_PAGE_SIZE", "70")
|
||
, ("CHUSQL_WEB_MAX_ROWS", "9")
|
||
, ("CHUSQL_WEB_MAX_SQL_LENGTH", "1234")
|
||
, ("CHUSQL_WEB_BODY_LIMIT", "2048")
|
||
, ("CHUSQL_WEB_COOKIE_SECURE", "1")
|
||
]
|
||
$ do
|
||
cfg <- loadWebConfig
|
||
wcSessionIdle cfg `shouldBe` 60
|
||
wcSessionMax cfg `shouldBe` 120
|
||
wcLoginMaxAttempts cfg `shouldBe` 3
|
||
wcLoginWindow cfg `shouldBe` 30
|
||
wcPageSize cfg `shouldBe` 7
|
||
wcMaxPageSize cfg `shouldBe` 70
|
||
wcMaxRows cfg `shouldBe` 9
|
||
wcMaxSqlLength cfg `shouldBe` 1234
|
||
wcBodyLimit cfg `shouldBe` 2048
|
||
wcCookieSecure cfg `shouldBe` True
|
||
it "unsetting the env var restores the default port 7777" $ do
|
||
withEnv [("CHUSQL_WEB_PORT", "8123")] $ do
|
||
tuned <- loadWebConfig
|
||
wcPort tuned `shouldBe` 8123
|
||
restored <- loadWebConfig
|
||
wcPort restored `shouldBe` 7777
|
||
|
||
describe "Launcher script (script/chusql.ps1)" $ do
|
||
it "script/ has chusql.ps1 / chusql.cmd, default port 7777, covering all three modules" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
batch <- readUtf8 (".." </> "script" </> "chusql.cmd")
|
||
batch `shouldSatisfy` T.isInfixOf "chusql.ps1"
|
||
script `shouldSatisfy` T.isInfixOf "chusql-storage"
|
||
script `shouldSatisfy` T.isInfixOf "chusql-web.exe"
|
||
script `shouldSatisfy` T.isInfixOf "/api/status"
|
||
script `shouldSatisfy` T.isInfixOf "7777"
|
||
it "has web / cli / config commands, cli is a placeholder (dev notice + still starts web)" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
script `shouldSatisfy` T.isInfixOf "CLI interface is under development."
|
||
script `shouldSatisfy` T.isInfixOf "'web'"
|
||
script `shouldSatisfy` T.isInfixOf "'cli'"
|
||
script `shouldSatisfy` T.isInfixOf "config set"
|
||
it "settings persist to a file (config set/unset/reset + precedence note)" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
script `shouldSatisfy` T.isInfixOf "chusql.settings.json"
|
||
script `shouldSatisfy` T.isInfixOf "command line > settings file > environment > default"
|
||
script `shouldSatisfy` T.isInfixOf "Save-SavedSettings"
|
||
it "child window hidden + logs flow back to the launcher window" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
script `shouldSatisfy` T.isInfixOf "-WindowStyle Hidden"
|
||
script `shouldSatisfy` T.isInfixOf "Show-NewLogLines"
|
||
script `shouldSatisfy` T.isInfixOf "RedirectStandardOutput"
|
||
it "the script exposes settings as switches (port/session/paging/storage tuning)" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_SESSION_IDLE"
|
||
script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_MAX_ROWS"
|
||
script `shouldSatisfy` T.isInfixOf "CHUSQL_BUFFER_POOL_SIZE"
|
||
script `shouldSatisfy` T.isInfixOf "CHUSQL_PAGE_SIZE"
|
||
it "the Rust artifact is named chusql-storage (no longer server)" $ do
|
||
script <- readUtf8 (".." </> "script" </> "chusql.ps1")
|
||
script `shouldSatisfy` T.isInfixOf "target\\release\\chusql-storage.exe"
|
||
script `shouldSatisfy` (not . T.isInfixOf "target\\release\\server.exe")
|
||
|
||
staticSpec :: FilePath -> Spec
|
||
staticSpec staticDir = describe "Static files" $ do
|
||
it "reads index.html from the temp dir" $ do
|
||
content <- readStatic staticDir "index.html"
|
||
content `shouldSatisfy` maybe False (not . BS.null)
|
||
it "an unreadable file gives Nothing" $
|
||
readStatic staticDir "nope.js" `shouldReturn` Nothing
|
||
it "a traversal path is unreadable" $
|
||
readStatic staticDir "../secret.token" `shouldReturn` Nothing
|
||
|
||
frontendBundleSpec :: Spec
|
||
frontendBundleSpec = describe "React frontend build artifacts" $ do
|
||
present <- runIO (doesDirectoryExist "static")
|
||
let asset name = "static" </> name
|
||
readAsset name = readUtf8 (asset name)
|
||
-- | static/ 是构建产物,没构建就跳过
|
||
check body
|
||
| present = body
|
||
| otherwise = pendingWith "static/ is not built; run npm run build under chusql-web/frontend"
|
||
it "entry HTML exists and is non-empty" $ check $ do
|
||
html <- BS.readFile (asset "index.html")
|
||
html `shouldSatisfy` (not . BS.null)
|
||
it "entry declares the Chinese page language" $ check $ do
|
||
html <- readAsset "index.html"
|
||
html `shouldSatisfy` T.isInfixOf "lang=\"zh-CN\""
|
||
it "entry loads the app script only from /static/" $ check $ do
|
||
html <- readAsset "index.html"
|
||
html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\""
|
||
it "entry loads styles only from /static/" $ check $ do
|
||
html <- readAsset "index.html"
|
||
html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\""
|
||
it "entry has no inline script" $ check $ do
|
||
html <- readAsset "index.html"
|
||
html `shouldSatisfy` (not . T.isInfixOf "<script>")
|
||
it "entry has no inline style" $ check $ do
|
||
html <- readAsset "index.html"
|
||
html `shouldSatisfy` (not . T.isInfixOf "style=")
|
||
html `shouldSatisfy` (not . T.isInfixOf "<style")
|
||
it "app script exists and is non-empty" $ check $ do
|
||
js <- BS.readFile (asset "app.js")
|
||
js `shouldSatisfy` (not . BS.null)
|
||
it "app script contains the centrally maintained Chinese UI copy" $ check $ do
|
||
js <- readAsset "app.js"
|
||
js `shouldSatisfy` T.isInfixOf "ChuSQL 数据库终端"
|
||
js `shouldSatisfy` T.isInfixOf "待提交变更"
|
||
it "main stylesheet uses the agreed Dark+ three-layer background" $ check $ do
|
||
css <- readAsset "style.css"
|
||
css `shouldSatisfy` T.isInfixOf "--bg-base:#1e1e1e"
|
||
css `shouldSatisfy` T.isInfixOf "--bg-elevated:#252526"
|
||
css `shouldSatisfy` T.isInfixOf "--bg-statusbar:#007acc"
|
||
it "own styles have no gradients or shadows" $ check $ do
|
||
css <- readAsset "style.css"
|
||
css `shouldSatisfy` (not . T.isInfixOf "gradient(")
|
||
css `shouldSatisfy` (not . T.isInfixOf "box-shadow")
|
||
it "SQL language pack exists and is non-empty" $ check $ do
|
||
sql <- BS.readFile (asset "sql.js")
|
||
sql `shouldSatisfy` (not . BS.null)
|
||
it "Monaco font exists and is non-empty" $ check $ do
|
||
font <- BS.readFile (asset "codicon.ttf")
|
||
font `shouldSatisfy` (not . BS.null)
|
||
it "Monaco worker exists and is non-empty" $ check $ do
|
||
names <- System.Directory.listDirectory "static"
|
||
let workers = filter (\name -> "editor.worker-" `isInfixOf` name && ".js" `isSuffixOf` name) names
|
||
workers `shouldSatisfy` (not . null)
|
||
mapM_ (\name -> BS.readFile (asset name) >>= (`shouldSatisfy` (not . BS.null))) workers
|
||
it "all build files pass the static file name whitelist" $ check $ do
|
||
names <- System.Directory.listDirectory "static"
|
||
mapM_ (\name -> safeRelative (T.pack name) `shouldBe` Just name) names
|
||
|
||
actionsSpec :: Spec
|
||
actionsSpec = describe "One-click actions -> one SQL statement (ChuSQL.Web.Actions)" $ do
|
||
it "create table: column types and identifiers are validated" $ do
|
||
createTableSql (CreateTableSpec "customers" [ColumnSpec "id" "int", ColumnSpec "name" "str"])
|
||
`shouldBe` Right "CREATE TABLE customers (id int, name str)"
|
||
createTableSql (CreateTableSpec "bad name" [ColumnSpec "id" "int"]) `shouldSatisfy` isLeftE
|
||
createTableSql (CreateTableSpec "t" []) `shouldSatisfy` isLeftE
|
||
createTableSql (CreateTableSpec "t" [ColumnSpec "id" "blob"]) `shouldSatisfy` isLeftE
|
||
createTableSql (CreateTableSpec "t" [ColumnSpec "1bad" "int"]) `shouldSatisfy` isLeftE
|
||
createTableSql (CreateTableSpec "t" [ColumnSpec "id" "BOOL"]) `shouldSatisfy` isRightE
|
||
it "string literal escaping: single quotes doubled, the rest unchanged" $ do
|
||
sqlLiteral (VStr "O'Brien") `shouldBe` "'O''Brien'"
|
||
sqlLiteral (VStr "a\\b") `shouldBe` "'a\\b'"
|
||
sqlLiteral (VInt 7) `shouldBe` "7"
|
||
sqlLiteral (VBool True) `shouldBe` "TRUE"
|
||
sqlLiteral (VBool False) `shouldBe` "FALSE"
|
||
it "statement shapes for insert / update / delete / index" $ do
|
||
insertRowSql "t" [("id", VInt 1), ("name", VStr "a")]
|
||
`shouldBe` Right "INSERT INTO t (id, name) VALUES (1, 'a')"
|
||
insertRowsSql "t" [[("id", VInt 1)], [("id", VInt 2)]]
|
||
`shouldBe` Right "INSERT INTO t (id) VALUES (1), (2)"
|
||
updateRowSql "t" 3 [("age", VInt 9)]
|
||
`shouldBe` Right "UPDATE t SET age = 9 WHERE id = 3"
|
||
deleteRowSql "t" 3 `shouldBe` Right "DELETE FROM t WHERE id = 3"
|
||
createIndexSql "t" "sku" `shouldBe` Right "CREATE INDEX ON t (sku)"
|
||
dropIndexSql "t" "sku" `shouldBe` Right "DROP INDEX ON t (sku)"
|
||
dropTableSql "t" `shouldBe` Right "DROP TABLE t"
|
||
it "injection shapes cannot get in (the whitelist runs before SQL is built)" $ do
|
||
dropTableSql "users; DROP TABLE users" `shouldSatisfy` isLeftE
|
||
createIndexSql "t" "c) --" `shouldSatisfy` isLeftE
|
||
insertRowSql "t" [("id; DROP", VInt 1)] `shouldSatisfy` isLeftE
|
||
insertRowSql "t" [] `shouldSatisfy` isLeftE
|
||
it "JSON values coerce by column type: int takes numbers/numeric strings, str takes strings, bool takes booleans/true|false" $ do
|
||
coerceValue TInt (A.Number 3) `shouldBe` Right (VInt 3)
|
||
coerceValue TInt (A.String "42") `shouldBe` Right (VInt 42)
|
||
coerceValue TInt (A.String "x") `shouldSatisfy` isLeftE
|
||
coerceValue TStr (A.String "hi") `shouldBe` Right (VStr "hi")
|
||
coerceValue TStr (A.Number 1) `shouldSatisfy` isLeftE
|
||
coerceValue TBool (A.Bool True) `shouldBe` Right (VBool True)
|
||
coerceValue TBool (A.String "false") `shouldBe` Right (VBool False)
|
||
coerceValue TBool (A.String "maybe") `shouldSatisfy` isLeftE
|
||
it "column type parsing: int/integer/str/text/varchar/bool/boolean are recognised" $ do
|
||
map columnTypeOf ["int", "INTEGER", "str", "text", "varchar", "bool", "boolean"]
|
||
`shouldBe` map Just [TInt, TInt, TStr, TStr, TStr, TBool, TBool]
|
||
columnTypeOf "blob" `shouldBe` Nothing
|
||
|
||
oneClickSpec :: FilePath -> Spec
|
||
oneClickSpec staticDir = describe "One-click action endpoints" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "create -> insert -> update -> delete -> drop through the structured endpoints (incl. bool columns and quote escaping)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
created <-
|
||
postWith
|
||
hs
|
||
"/api/tables"
|
||
"{\"name\":\"customers\",\"columns\":[{\"name\":\"id\",\"type\":\"int\"},{\"name\":\"name\",\"type\":\"str\"},{\"name\":\"vip\",\"type\":\"bool\"}]}"
|
||
check (hasStatus created 200)
|
||
inserted <- postWith hs "/api/tables/customers/rows" "{\"values\":{\"id\":1,\"name\":\"O'Brien\",\"vip\":true}}"
|
||
check (hasStatus inserted 200)
|
||
page <- getWith hs "/api/tables/customers/rows"
|
||
check (asInt (at "total" (jsonBody page)) `shouldBe` 1)
|
||
check (firstRow page `shouldBe` [A.Number 1, A.String "O'Brien", A.Bool True])
|
||
patched <-
|
||
request
|
||
"PATCH"
|
||
"/api/tables/customers/rows/1"
|
||
(("Content-Type", "application/json") : hs)
|
||
"{\"values\":{\"vip\":false,\"name\":\"O''Brien\"}}"
|
||
check (hasStatus patched 200)
|
||
afterEdit <- getWith hs "/api/tables/customers/rows"
|
||
check (firstRow afterEdit `shouldBe` [A.Number 1, A.String "O''Brien", A.Bool False])
|
||
removed <- request "DELETE" "/api/tables/customers/rows/1" hs ""
|
||
check (hasStatus removed 200)
|
||
empty <- getWith hs "/api/tables/customers/rows"
|
||
check (asInt (at "total" (jsonBody empty)) `shouldBe` 0)
|
||
dropped <- request "DELETE" "/api/tables/customers" hs ""
|
||
check (hasStatus dropped 200)
|
||
gone <- getWith hs "/api/tables/customers"
|
||
check (hasStatus gone 404)
|
||
it "bad input always gives 400/404, never a half-built SQL" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
badName <- postWith hs "/api/tables" "{\"name\":\"bad name\",\"columns\":[{\"name\":\"id\",\"type\":\"int\"}]}"
|
||
check (hasStatus badName 400)
|
||
unknownColumn <- postWith hs "/api/tables/users/rows" "{\"values\":{\"nope\":1}}"
|
||
check (hasStatus unknownColumn 400)
|
||
missingColumns <- postWith hs "/api/tables/users/rows" "{\"values\":{\"id\":99}}"
|
||
check (hasStatus missingColumns 400)
|
||
badBody <- postWith hs "/api/tables/users/rows" "not json"
|
||
check (hasStatus badBody 400)
|
||
badId <- request "PATCH" "/api/tables/users/rows/abc" (("Content-Type", "application/json") : hs) "{\"values\":{\"age\":1}}"
|
||
check (hasStatus badId 400)
|
||
noTable <- postWith hs "/api/tables/nope/rows" "{\"values\":{\"id\":1}}"
|
||
check (hasStatus noTable 404)
|
||
badType <- postWith hs "/api/tables/users/rows" "{\"values\":{\"id\":1,\"name\":\"x\",\"age\":\"old\"}}"
|
||
check (hasStatus badType 400)
|
||
it "index: integer columns work, string columns are rejected by the API (duplicate rejection is up to real storage, see e2e)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
okIndex <- postWith hs "/api/tables/users/indexes" "{\"column\":\"age\"}"
|
||
check (hasStatus okIndex 200)
|
||
stringIndex <- postWith hs "/api/tables/users/indexes" "{\"column\":\"name\"}"
|
||
check (hasStatus stringIndex 400)
|
||
dropped <- request "DELETE" "/api/tables/users/indexes/age" hs ""
|
||
check (hasStatus dropped 200)
|
||
builtIn <- request "DELETE" "/api/tables/users/indexes/id" hs ""
|
||
check (hasStatus builtIn 400)
|
||
it "drop column: the column is gone but rows stay; built-in id and unknown column/table are rejected" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
created <- postWith hs "/api/tables" "{\"name\":\"drop_col_t\",\"columns\":[{\"name\":\"id\",\"type\":\"int\"},{\"name\":\"code\",\"type\":\"int\"},{\"name\":\"note\",\"type\":\"str\"}]}"
|
||
check (hasStatus created 200)
|
||
inserted <- postWith hs "/api/tables/drop_col_t/rows" "{\"values\":{\"id\":1,\"code\":7,\"note\":\"a\"}}"
|
||
check (hasStatus inserted 200)
|
||
droppedColumn <- request "DELETE" "/api/tables/drop_col_t/columns/code" hs ""
|
||
check (hasStatus droppedColumn 200)
|
||
info <- getWith hs "/api/tables/drop_col_t"
|
||
check $ do
|
||
let names = map (asText . at "name") (items (at "columns" (jsonBody info)))
|
||
names `shouldBe` ["id", "note"]
|
||
page <- getWith hs "/api/tables/drop_col_t/rows"
|
||
check (hasStatus page 200)
|
||
check (map asText (items (at "columns" (jsonBody page))) `shouldBe` ["id", "note"])
|
||
check (firstRow page `shouldBe` [A.Number 1, A.String "a"])
|
||
builtInColumn <- request "DELETE" "/api/tables/drop_col_t/columns/id" hs ""
|
||
check (hasStatus builtInColumn 400)
|
||
unknownColumn <- request "DELETE" "/api/tables/drop_col_t/columns/nope" hs ""
|
||
check (hasStatus unknownColumn 400)
|
||
unknownTable <- request "DELETE" "/api/tables/nope/columns/code" hs ""
|
||
check (hasStatus unknownTable 404)
|
||
it "demo data: fills what is missing, repeated clicks do not re-seed (users/orders already exist in memory)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
first <- postWith hs "/api/demo-data" "{}"
|
||
check (hasStatus first 200)
|
||
check (arrayLen (at "created" (jsonBody first)) + arrayLen (at "skipped" (jsonBody first)) `shouldBe` 3)
|
||
products <- getWith hs "/api/tables/products/rows?limit=100"
|
||
check (asInt (at "total" (jsonBody products)) `shouldBe` 8)
|
||
users <- getWith hs "/api/tables/users/rows?limit=100"
|
||
check (asInt (at "total" (jsonBody users)) `shouldBe` 5)
|
||
again <- postWith hs "/api/demo-data" "{}"
|
||
check (hasStatus again 200)
|
||
check (arrayLen (at "created" (jsonBody again)) `shouldBe` 0)
|
||
check (arrayLen (at "skipped" (jsonBody again)) `shouldBe` 3)
|
||
usersAgain <- getWith hs "/api/tables/users/rows?limit=100"
|
||
check (asInt (at "total" (jsonBody usersAgain)) `shouldBe` 5)
|
||
|
||
authSpec :: FilePath -> Spec
|
||
authSpec staticDir = do
|
||
describe "Auth: not logged in" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "health check needs no login" $
|
||
get "/api/health" `shouldRespondWith` 200
|
||
it "table list gives 401" $
|
||
get "/api/tables" `shouldRespondWith` 401
|
||
it "table rows give 401" $
|
||
get "/api/tables/users/rows" `shouldRespondWith` 401
|
||
it "SQL console gives 401" $
|
||
jsonRequest methodPost "/api/query" "{\"sql\":\"SELECT * FROM users\"}" `shouldRespondWith` 401
|
||
it "session lookup gives 401" $
|
||
get "/api/session" `shouldRespondWith` 401
|
||
it "status needs no login and reports storage up (the memory backend is always up)" $ do
|
||
res <- get "/api/status"
|
||
check (hasStatus res 200)
|
||
check (asText (at "storage" (jsonBody res)) `shouldBe` "up")
|
||
it "the home page needs no login (the frontend switches to the login view)" $
|
||
get "/" `shouldRespondWith` 200
|
||
|
||
describe "Auth: login" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "correct password: 204 + session cookie (HttpOnly / SameSite=Strict)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
check (hasStatus res 204)
|
||
let rawCookie = fmap TE.decodeUtf8 (lookup "Set-Cookie" (WT.simpleHeaders res))
|
||
check (rawCookie `shouldSatisfy` maybe False (T.isInfixOf "HttpOnly"))
|
||
check (rawCookie `shouldSatisfy` maybe False (T.isInfixOf "SameSite=Strict"))
|
||
check (sessionCookieOf res `shouldSatisfy` maybe False (not . T.null))
|
||
it "wrong password gives 401" $
|
||
loginAs "admin" "nope" `shouldRespondWith` 401
|
||
it "the username is case-insensitive but the password must match" $
|
||
loginAs "ADMIN" "s3cret" `shouldRespondWith` 204
|
||
it "non-JSON body gives 400" $
|
||
jsonRequest methodPost "/api/login" "{not json" `shouldRespondWith` 400
|
||
it "missing field gives 400" $
|
||
jsonRequest methodPost "/api/login" "{\"user\":\"admin\"}" `shouldRespondWith` 400
|
||
it "empty password gives 400" $
|
||
jsonRequest methodPost "/api/login" "{\"user\":\"admin\",\"password\":\"\"}" `shouldRespondWith` 400
|
||
it "a cookie allows session lookup" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
cs <- getWith (cookieHeaders res) "/api/session"
|
||
check (hasStatus cs 200)
|
||
check (asText (at "user" (jsonBody cs)) `shouldBe` "admin")
|
||
it "logout invalidates the session immediately" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
out <- postWith (cookieHeaders res) "/api/logout" "{}"
|
||
check (hasStatus out 204)
|
||
gone <- getWith (cookieHeaders res) "/api/tables"
|
||
check (hasStatus gone 401)
|
||
it "a forged token gets nothing" $
|
||
getWith [("Cookie", "chusql_session=deadbeef")] "/api/tables" `shouldRespondWith` 401
|
||
|
||
describe "Auth: login failure rate limiting" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "after 5 failures the 6th is 429 (even with the right password)" $ do
|
||
mapM_ (const (loginAs "admin" "wrong")) [1 :: Int, 2, 3, 4, 5]
|
||
blocked <- loginAs "admin" "s3cret"
|
||
check (hasStatus blocked 429)
|
||
check (lookup "Retry-After" (WT.simpleHeaders blocked) `shouldSatisfy` maybe False (not . BS.null))
|
||
|
||
catalogSpec :: FilePath -> Spec
|
||
catalogSpec staticDir = describe "Catalogue and paged browsing" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "table list carries row counts, columns, indexes and stats" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
list <- getWith (cookieHeaders res) "/api/tables"
|
||
check (hasStatus list 200)
|
||
check $ do
|
||
let tables = items (jsonBody list)
|
||
map (asText . at "table") tables `shouldBe` ["users", "orders"]
|
||
case tables of
|
||
[] -> expectationFailure "the table list is empty"
|
||
(usersInfo : _) -> do
|
||
asInt (at "rowCount" usersInfo) `shouldBe` 5
|
||
map (asText . at "name") (items (at "columns" usersInfo)) `shouldBe` ["id", "name", "age"]
|
||
let cols = items (at "columns" usersInfo)
|
||
case cols of
|
||
(firstCol : secondCol : _) -> do
|
||
at "primaryKey" firstCol `shouldBe` A.Bool True
|
||
at "prime" firstCol `shouldBe` A.Bool True
|
||
asInt (at "distinct" firstCol) `shouldBe` 5
|
||
at "primaryKey" secondCol `shouldBe` A.Bool False
|
||
at "prime" secondCol `shouldBe` A.Bool False
|
||
at "indexed" secondCol `shouldBe` A.Bool False
|
||
_ -> expectationFailure "users should have three columns"
|
||
it "single table structure" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
info <- getWith (cookieHeaders res) "/api/tables/users"
|
||
check (hasStatus info 200)
|
||
check (asText (at "table" (jsonBody info)) `shouldBe` "users")
|
||
it "missing table gives 404" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
missing <- getWith (cookieHeaders res) "/api/tables/nope"
|
||
check (hasStatus missing 404)
|
||
it "invalid table name gives 400 (never built into SQL)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- getWith (cookieHeaders res) "/api/tables/1bad/rows"
|
||
check (hasStatus bad 400)
|
||
it "paging: limit/offset apply and total is the full row count" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?limit=2&offset=1"
|
||
check (hasStatus page 200)
|
||
check $ do
|
||
let body = jsonBody page
|
||
asInt (at "total" body) `shouldBe` 5
|
||
asInt (at "limit" body) `shouldBe` 2
|
||
asInt (at "offset" body) `shouldBe` 1
|
||
let rows = items (at "rows" body)
|
||
length rows `shouldBe` 2
|
||
nth 0 rows `shouldBe` Just (A.Array (V.fromList [A.Number 2, A.String "user2", A.Number 22]))
|
||
it "an out-of-range limit is clamped to the cap of 500" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?limit=99999"
|
||
check (asInt (at "limit" (jsonBody page)) `shouldBe` 500)
|
||
it "non-integer limit gives 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- getWith (cookieHeaders res) "/api/tables/users/rows?limit=abc"
|
||
check (hasStatus bad 400)
|
||
it "empty tables are browsable too (columns come from the catalogue)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
created <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"CREATE TABLE empty_t (id int, name str)\"}"
|
||
check (hasStatus created 200)
|
||
emptyPage <- getWith (cookieHeaders res) "/api/tables/empty_t/rows"
|
||
check (hasStatus emptyPage 200)
|
||
check $ do
|
||
asInt (at "total" (jsonBody emptyPage)) `shouldBe` 0
|
||
map asText (items (at "columns" (jsonBody emptyPage))) `shouldBe` ["id", "name"]
|
||
it "filter param: equality per column, multiple conditions are AND (the WHERE row above the data grid)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
one <- getWith hs "/api/tables/users/rows?filter=%5B%7B%22column%22%3A%22name%22%2C%22value%22%3A%22user2%22%7D%5D"
|
||
check (hasStatus one 200)
|
||
check $ do
|
||
asInt (at "total" (jsonBody one)) `shouldBe` 1
|
||
firstRow one `shouldBe` [A.Number 2, A.String "user2", A.Number 22]
|
||
two <-
|
||
getWith
|
||
hs
|
||
"/api/tables/users/rows?filter=%5B%7B%22column%22%3A%22age%22%2C%22value%22%3A%2223%22%7D%2C%7B%22column%22%3A%22id%22%2C%22value%22%3A%223%22%7D%5D"
|
||
check $ do
|
||
asInt (at "total" (jsonBody two)) `shouldBe` 1
|
||
firstRow two `shouldBe` [A.Number 3, A.String "user3", A.Number 23]
|
||
it "filter param: a blank cell counts as unfilled; unknown column / uncoercible value / non-JSON give 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
let hs = cookieHeaders res
|
||
blank <- getWith hs "/api/tables/users/rows?filter=%5B%7B%22column%22%3A%22name%22%2C%22value%22%3A%22%22%7D%5D"
|
||
check (asInt (at "total" (jsonBody blank)) `shouldBe` 5)
|
||
unknown <- getWith hs "/api/tables/users/rows?filter=%5B%7B%22column%22%3A%22nope%22%2C%22value%22%3A%22x%22%7D%5D"
|
||
check (hasStatus unknown 400)
|
||
badType <- getWith hs "/api/tables/users/rows?filter=%5B%7B%22column%22%3A%22age%22%2C%22value%22%3A%22old%22%7D%5D"
|
||
check (hasStatus badType 400)
|
||
notJson <- getWith hs "/api/tables/users/rows?filter=oops"
|
||
check (hasStatus notJson 400)
|
||
|
||
-- | 换一组更小的上限再跑
|
||
limitsSpec :: FilePath -> Spec
|
||
limitsSpec staticDir = describe "Paging and row limits follow the config" $ do
|
||
appEnv <- runIO (freshWorld staticDir)
|
||
runIO (setLive appEnv (defaultLive{lvPageSize = 1, lvMaxPageSize = 2, lvMaxRows = 2}))
|
||
with (webApp appEnv) $ do
|
||
it "a missing limit uses the configured page size" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows"
|
||
check (hasStatus page 200)
|
||
check (asInt (at "limit" (jsonBody page)) `shouldBe` 1)
|
||
it "a limit above the cap is clamped to maxPageSize" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?limit=99"
|
||
check (asInt (at "limit" (jsonBody page)) `shouldBe` 2)
|
||
it "query results cap at maxRows rows and set truncated" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
out <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT * FROM users\"}"
|
||
check (hasStatus out 200)
|
||
check (length (items (at "rows" (jsonBody out))) `shouldBe` 2)
|
||
check (at "truncated" (jsonBody out) `shouldBe` A.Bool True)
|
||
|
||
sqlSpec :: FilePath -> Spec
|
||
sqlSpec staticDir = describe "SQL console" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "SELECT returns columns and rows" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
out <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT name, age FROM users WHERE age > 22 ORDER BY age\"}"
|
||
check (hasStatus out 200)
|
||
check $ do
|
||
let body = jsonBody out
|
||
map asText (items (at "columns" body)) `shouldBe` ["name", "age"]
|
||
length (items (at "rows" body)) `shouldBe` 3
|
||
asInt (at "rowCount" body) `shouldBe` 3
|
||
it "inserted rows can be queried back" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
_ <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"INSERT INTO users (id, name, age) VALUES (9, 'newbie', 31)\"}"
|
||
out <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT * FROM users WHERE id = 9\"}"
|
||
check (length (items (at "rows" (jsonBody out))) `shouldBe` 1)
|
||
it "multi-row INSERT is supported" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
_ <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"INSERT INTO users (id, name, age) VALUES (11, 'a', 1), (12, 'b', 2)\"}"
|
||
out <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT * FROM users WHERE id > 10\"}"
|
||
check (length (items (at "rows" (jsonBody out))) `shouldBe` 2)
|
||
it "CREATE TABLE / DROP TABLE end-to-end" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
created <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"CREATE TABLE demo_t (id int, note str)\"}"
|
||
check (hasStatus created 200)
|
||
dropped <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"DROP TABLE demo_t\"}"
|
||
check (hasStatus dropped 200)
|
||
it "syntax error: 400 + the original message" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT FROM WHERE\"}"
|
||
check (hasStatus bad 400)
|
||
check $ do
|
||
asText (at "error" (jsonBody bad)) `shouldBe` "query_error"
|
||
asText (at "message" (jsonBody bad)) `shouldSatisfy` (not . T.null)
|
||
it "missing table gives 404" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
missing <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT * FROM nope\"}"
|
||
check (hasStatus missing 404)
|
||
it "empty sql gives 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\" \"}"
|
||
check (hasStatus bad 400)
|
||
it "non-JSON body gives 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- postWith (cookieHeaders res) "/api/query" "oops"
|
||
check (hasStatus bad 400)
|
||
|
||
-- | 设置接口:读写配置与改口令
|
||
settingsSpec :: FilePath -> Spec
|
||
settingsSpec staticDir = do
|
||
describe "Settings endpoints" $
|
||
withWorldAt staticDir "settings" $ \appEnv -> do
|
||
it "not logged in gives 401" $
|
||
get "/api/settings" `shouldRespondWith` 401
|
||
it "read settings: every catalogue key is present and secrets only report whether set" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
body <- getWith (cookieHeaders res) "/api/settings"
|
||
check (hasStatus body 200)
|
||
check $ do
|
||
let payload = jsonBody body
|
||
entries = items (at "items" payload)
|
||
at "owner" payload `shouldBe` A.Bool True
|
||
T.null (asText (at "file" payload)) `shouldBe` False
|
||
length entries `shouldBe` length settingCatalogue
|
||
map (asText . at "key") entries `shouldBe` map siKey settingCatalogue
|
||
let secrets = [i | i <- entries, asText (at "kind" i) == "secret"]
|
||
secrets `shouldSatisfy` (not . null)
|
||
map (asText . at "value") secrets `shouldSatisfy` all T.null
|
||
let port = headEntry "port" entries
|
||
asText (at "source" port) `shouldBe` "default"
|
||
asText (at "value" port) `shouldBe` "7777"
|
||
at "rootOnly" port `shouldBe` A.Bool True
|
||
at "editable" port `shouldBe` A.Bool True
|
||
at "restart" port `shouldBe` A.Bool True
|
||
at "live" port `shouldBe` A.Bool False
|
||
let rows = headEntry "rows-per-page" entries
|
||
at "live" rows `shouldBe` A.Bool True
|
||
at "restart" rows `shouldBe` A.Bool False
|
||
it "write settings: only catalogue keys are accepted and ints reject letters" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
unknown <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"nope\":\"1\"}}"
|
||
check (hasStatus unknown 400)
|
||
badType <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"rows-per-page\":\"abc\"}}"
|
||
check (hasStatus badType 400)
|
||
empty <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{}}"
|
||
check (hasStatus empty 400)
|
||
it "write settings: live keys apply at once (page size + body limit)" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
saved <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"rows-per-page\":\"2\",\"body-limit\":\"200\"}}"
|
||
check (hasStatus saved 200)
|
||
check (map asText (items (at "applied" (jsonBody saved))) `shouldMatchList` ["rows-per-page", "body-limit"])
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows"
|
||
check (asInt (at "limit" (jsonBody page)) `shouldBe` 2)
|
||
live <- liftIO (readLive appEnv)
|
||
check (lvPageSize live `shouldBe` 2)
|
||
check (lvBodyLimit live `shouldBe` 200)
|
||
big <- jsonRequest methodPost "/api/query" (BL.fromStrict (BS.replicate 400 97))
|
||
check (hasStatus big 413)
|
||
it "write settings: restart-only keys are named" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
saved <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"static-dir\":\"static\"}}"
|
||
check (hasStatus saved 200)
|
||
check (map asText (items (at "restartRequired" (jsonBody saved))) `shouldContain` ["static-dir"])
|
||
it "write settings: persisted values survive the next start (read back) and are sourced as settings file" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
_ <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"max-rows\":\"77\"}}"
|
||
stored <- liftIO (readSettingsFile (aeSettingsFile appEnv))
|
||
check (Map.lookup "max-rows" stored `shouldBe` Just "77")
|
||
body <- getWith (cookieHeaders res) "/api/settings"
|
||
check $ do
|
||
let entries = items (at "items" (jsonBody body))
|
||
asText (at "source" (headEntry "max-rows" entries)) `shouldBe` "settings file"
|
||
asText (at "value" (headEntry "max-rows" entries)) `shouldBe` "77"
|
||
it "changing the max-rows setting truncates query results" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
_ <- putWith (cookieHeaders res) "/api/settings" "{\"values\":{\"max-rows\":\"2\"}}"
|
||
out <- postWith (cookieHeaders res) "/api/query" "{\"sql\":\"SELECT * FROM users\"}"
|
||
check (hasStatus out 200)
|
||
check (length (items (at "rows" (jsonBody out))) `shouldBe` 2)
|
||
check (at "truncated" (jsonBody out) `shouldBe` A.Bool True)
|
||
it "non-config account: critical settings give 403, normal ones pass" $ do
|
||
guest <- liftIO (createSession (aeSessions appEnv) "guest")
|
||
let guestHeaders = [("Cookie", TE.encodeUtf8 ("chusql_session=" <> guest))]
|
||
forbidden <- putWith guestHeaders "/api/settings" "{\"values\":{\"port\":\"9999\"}}"
|
||
check (hasStatus forbidden 403)
|
||
check (asText (at "error" (jsonBody forbidden)) `shouldBe` "forbidden")
|
||
allowed <- putWith guestHeaders "/api/settings" "{\"values\":{\"rows-per-page\":\"4\"}}"
|
||
check (hasStatus allowed 200)
|
||
reloaded <- getWith guestHeaders "/api/settings"
|
||
check $ do
|
||
let entries = items (at "items" (jsonBody reloaded))
|
||
port = headEntry "port" entries
|
||
asText (at "value" port) `shouldBe` "7777"
|
||
at "editable" port `shouldBe` A.Bool False
|
||
at "editable" (headEntry "rows-per-page" entries) `shouldBe` A.Bool True
|
||
|
||
describe "Account: change password" $
|
||
withWorldAt staticDir "password" $ \appEnv -> do
|
||
it "not logged in gives 401" $
|
||
postWith [] "/api/account/password" "{\"current\":\"s3cret\",\"next\":\"longenough\"}"
|
||
`shouldRespondWith` 401
|
||
it "wrong current password: 400 bad_password" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- postWith (cookieHeaders res) "/api/account/password" "{\"current\":\"nope\",\"next\":\"longenough\"}"
|
||
check (hasStatus bad 400)
|
||
check (asText (at "error" (jsonBody bad)) `shouldBe` "bad_password")
|
||
it "new password too short / same as the old one: 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
short <- postWith (cookieHeaders res) "/api/account/password" "{\"current\":\"s3cret\",\"next\":\"tiny\"}"
|
||
check (hasStatus short 400)
|
||
same <- postWith (cookieHeaders res) "/api/account/password" "{\"current\":\"s3cret\",\"next\":\"s3cret\"}"
|
||
check (hasStatus same 400)
|
||
it "after success: the new password logs in, the old one fails, other sessions are kicked and the hash is persisted" $ do
|
||
tokenA <- liftIO (createSession (aeSessions appEnv) "admin")
|
||
tokenB <- liftIO (createSession (aeSessions appEnv) "admin")
|
||
let hdrs token = [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token))]
|
||
changed <- postWith (hdrs tokenA) "/api/account/password" "{\"current\":\"s3cret\",\"next\":\"brand-new-pass\"}"
|
||
check (hasStatus changed 204)
|
||
stillThere <- getWith (hdrs tokenA) "/api/session"
|
||
check (hasStatus stillThere 200)
|
||
kicked <- getWith (hdrs tokenB) "/api/session"
|
||
check (hasStatus kicked 401)
|
||
fresh <- loginAs "admin" "brand-new-pass"
|
||
check (hasStatus fresh 204)
|
||
stale <- loginAs "admin" "s3cret"
|
||
check (hasStatus stale 401)
|
||
stored <- liftIO (readSettingsFile (aeSettingsFile appEnv))
|
||
check (Map.lookup "password-hash" stored `shouldSatisfy` maybe False hashLooksValid)
|
||
check (Map.member "password" stored `shouldBe` False)
|
||
|
||
-- | 一份完整的合法 IDE 设置请求体
|
||
uiSettingsBody :: BL.ByteString
|
||
uiSettingsBody = "{\"uiFonts\":[\"JetBrains Mono\",\"Consolas\"],\"gridFonts\":[\"Consolas\"],\"sqlFonts\":[\"Mono\"],\"uiFontSize\":13,\"gridFontSize\":12,\"gridRowHeight\":24,\"sqlFontSize\":13,\"sqlLineHeight\":20,\"sqlTabSize\":2,\"pageSize\":200,\"nullText\":\"NULL\",\"sqlLineNumbers\":true,\"autocomplete\":true,\"minimap\":false}"
|
||
|
||
-- | 上面那份设置解出来的值
|
||
fullUiSettings :: A.Value
|
||
fullUiSettings = fromMaybe A.Null (A.decode uiSettingsBody)
|
||
|
||
-- | 在合法设置上换掉一个字段(试坏值用)
|
||
withUiField :: Text -> A.Value -> A.Value
|
||
withUiField key value = case fullUiSettings of
|
||
A.Object o -> A.Object (KM.insert (K.fromText key) value o)
|
||
other -> other
|
||
|
||
-- | n 项字体链
|
||
fontChainValue :: Int -> A.Value
|
||
fontChainValue n = A.Array (V.fromList (replicate n (A.String "Mono")))
|
||
|
||
uiSettingsSpec :: FilePath -> Spec
|
||
uiSettingsSpec staticDir = do
|
||
describe "IDE settings validation" $ do
|
||
it "accepts a complete valid object" $
|
||
validateUiSettings fullUiSettings `shouldSatisfy` isRightE
|
||
it "rejects an unknown key" $
|
||
validateUiSettings (withUiField "nope" (A.Bool True)) `shouldSatisfy` isLeftE
|
||
it "rejects a string where an integer is expected" $
|
||
validateUiSettings (withUiField "uiFontSize" (A.String "13")) `shouldSatisfy` isLeftE
|
||
it "rejects an integer outside its range" $ do
|
||
validateUiSettings (withUiField "uiFontSize" (A.Number 10)) `shouldSatisfy` isLeftE
|
||
validateUiSettings (withUiField "uiFontSize" (A.Number 17)) `shouldSatisfy` isLeftE
|
||
it "rejects a font chain longer than ten entries" $
|
||
validateUiSettings (withUiField "uiFonts" (fontChainValue 11)) `shouldSatisfy` isLeftE
|
||
it "rejects a blank font name" $
|
||
validateUiSettings (withUiField "gridFonts" (A.Array (V.fromList [A.String " "]))) `shouldSatisfy` isLeftE
|
||
it "rejects a number where a boolean is expected" $
|
||
validateUiSettings (withUiField "minimap" (A.Number 1)) `shouldSatisfy` isLeftE
|
||
it "names the offending key in the error" $
|
||
case validateUiSettings (withUiField "pageSize" (A.Number 5)) of
|
||
Left err -> err `shouldSatisfy` isInfixOf "pageSize"
|
||
Right _ -> expectationFailure "expected a range error"
|
||
|
||
describe "IDE settings file" $ do
|
||
it "candidates look for the file in script/ first" $
|
||
uiSettingsFileCandidates
|
||
`shouldBe` [ "script" </> "chusql.ui.settings.json"
|
||
, ".." </> "script" </> "chusql.ui.settings.json"
|
||
, "chusql.ui.settings.json"
|
||
]
|
||
it "resolution picks one of the candidates" $ do
|
||
path <- resolveUiSettingsFile
|
||
path `shouldSatisfy` (`elem` uiSettingsFileCandidates)
|
||
it "a missing file reads back as an empty object" $ do
|
||
path <- tempUiSettingsPath "missing"
|
||
removeIfExists path
|
||
readUiSettings path `shouldReturn` A.Object KM.empty
|
||
it "writing then reading round-trips the map and creates the directory" $ do
|
||
tmp <- getTemporaryDirectory
|
||
let dir = tmp </> "chusql-web-test-ui-file" </> "nested"
|
||
path = dir </> "chusql.ui.settings.json"
|
||
stored = Map.fromList [("minimap", A.Bool True), ("uiFontSize", A.Number 13)]
|
||
removeIfExists path
|
||
written <- writeUiSettings path stored
|
||
written `shouldBe` Right ()
|
||
readUiSettings path `shouldReturn` A.Object (KM.fromList [("minimap", A.Bool True), ("uiFontSize", A.Number 13)])
|
||
|
||
describe "IDE settings endpoints" $
|
||
withWorldAt staticDir "ui-settings" $ \appEnv -> do
|
||
it "not logged in: GET and PUT both give 401" $ do
|
||
get "/api/ui-settings" `shouldRespondWith` 401
|
||
putWith [] "/api/ui-settings" uiSettingsBody `shouldRespondWith` 401
|
||
it "a missing file reads back as an empty object" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
body <- getWith (cookieHeaders res) "/api/ui-settings"
|
||
check (hasStatus body 200)
|
||
check (jsonBody body `shouldBe` A.Object KM.empty)
|
||
it "a valid body is stored whole and read back unchanged" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
saved <- putWith (cookieHeaders res) "/api/ui-settings" uiSettingsBody
|
||
check (hasStatus saved 204)
|
||
body <- getWith (cookieHeaders res) "/api/ui-settings"
|
||
check (hasStatus body 200)
|
||
check (jsonBody body `shouldBe` fullUiSettings)
|
||
it "an unknown key gives 400 bad_request" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- putWith (cookieHeaders res) "/api/ui-settings" "{\"nope\":1}"
|
||
check (hasStatus bad 400)
|
||
check (asText (at "error" (jsonBody bad)) `shouldBe` "bad_request")
|
||
it "a non-object JSON body gives 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
bad <- putWith (cookieHeaders res) "/api/ui-settings" "[]"
|
||
check (hasStatus bad 400)
|
||
it "a body over the live limit gives 413" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
check $ do
|
||
live <- readLive appEnv
|
||
setLive appEnv live{lvBodyLimit = 16}
|
||
big <- putWith (cookieHeaders res) "/api/ui-settings" uiSettingsBody
|
||
check (hasStatus big 413)
|
||
|
||
sortSpec :: FilePath -> Spec
|
||
sortSpec staticDir = describe "Sorting while browsing a table" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "asc: the first row has the smallest age" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?sort=age&dir=asc"
|
||
check (hasStatus page 200)
|
||
check $ do
|
||
asText (at "sort" (jsonBody page)) `shouldBe` "age"
|
||
asText (at "dir" (jsonBody page)) `shouldBe` "asc"
|
||
firstRow page `shouldBe` [A.Number 1, A.String "user1", A.Number 21]
|
||
it "desc: the first row has the largest age" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?sort=age&dir=desc"
|
||
check (hasStatus page 200)
|
||
check (firstRow page `shouldBe` [A.Number 5, A.String "user5", A.Number 25])
|
||
it "unknown column / invalid column name: 400" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
missing <- getWith (cookieHeaders res) "/api/tables/users/rows?sort=nope"
|
||
check (hasStatus missing 400)
|
||
invalid <- getWith (cookieHeaders res) "/api/tables/users/rows?sort=age%3B%20DROP%20TABLE%20users"
|
||
check (hasStatus invalid 400)
|
||
it "the direction accepts only asc/desc, anything else is asc" $ do
|
||
res <- loginAs "admin" "s3cret"
|
||
page <- getWith (cookieHeaders res) "/api/tables/users/rows?sort=age&dir=sideways"
|
||
check (hasStatus page 200)
|
||
check (asText (at "dir" (jsonBody page)) `shouldBe` "asc")
|
||
|
||
securitySpec :: FilePath -> Spec
|
||
securitySpec staticDir = do
|
||
describe "Security: response headers and static files" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "the per-page style nonce differs each time and matches the CSP" $ do
|
||
first <- get "/"
|
||
second <- get "/static/index.html"
|
||
let responsePolicy res = fromMaybe "" (lookup "Content-Security-Policy" (WT.simpleHeaders res))
|
||
nonce res = BSC.takeWhile (/= '\'') (BSC.drop 7 (snd (BSC.breakSubstring "'nonce-" (responsePolicy res))))
|
||
check $ do
|
||
BS.length (nonce first) `shouldBe` 64
|
||
nonce first `shouldNotBe` nonce second
|
||
BL.toStrict (WT.simpleBody first) `shouldSatisfy` BS.isInfixOf (nonce first)
|
||
BL.toStrict (WT.simpleBody second) `shouldSatisfy` BS.isInfixOf (nonce second)
|
||
responsePolicy first `shouldSatisfy` (not . BS.isInfixOf "unsafe-inline")
|
||
responsePolicy first `shouldSatisfy` BS.isInfixOf "script-src 'self';"
|
||
length (filter ((== "Content-Security-Policy") . fst) (WT.simpleHeaders first)) `shouldBe` 1
|
||
it "every response carries CSP / nosniff / DENY / no-store" $ do
|
||
res <- get "/api/health"
|
||
let headerNames = map fst (WT.simpleHeaders res)
|
||
check $ do
|
||
headerNames `shouldContain` ["Content-Security-Policy"]
|
||
headerNames `shouldContain` ["X-Content-Type-Options"]
|
||
headerNames `shouldContain` ["X-Frame-Options"]
|
||
headerNames `shouldContain` ["Cache-Control"]
|
||
it "static JS uses the right Content-Type" $ do
|
||
res <- get "/static/app.js"
|
||
check (hasStatus res 200)
|
||
check (lookup "Content-Type" (WT.simpleHeaders res) `shouldBe` Just "text/javascript; charset=utf-8")
|
||
it "a missing static file gives 404" $
|
||
get "/static/nope.js" `shouldRespondWith` 404
|
||
it "URL-encoded traversal is rejected" $
|
||
get "/static/%2e%2e%2fsecret.token" `shouldRespondWith` 404
|
||
it "files outside the whitelist are unreadable" $
|
||
get "/static/secret.token" `shouldRespondWith` 404
|
||
it "an unmatched path gives 404 (JSON)" $ do
|
||
res <- get "/api/nope"
|
||
check (hasStatus res 404)
|
||
check (asText (at "error" (jsonBody res)) `shouldBe` "not_found")
|
||
describe "Security: cross-site and body size" $
|
||
withWorld staticDir $ \_ -> do
|
||
it "a cross-site Origin POST gives 403" $
|
||
postWith
|
||
[("Origin", "http://evil.example"), ("Host", "127.0.0.1:8080")]
|
||
"/api/login"
|
||
(loginBody "admin" "s3cret")
|
||
`shouldRespondWith` 403
|
||
it "a same-origin Origin POST works as usual (204 with the right password)" $ do
|
||
res <-
|
||
postWith
|
||
[("Origin", "http://127.0.0.1:8080"), ("Host", "127.0.0.1:8080")]
|
||
"/api/login"
|
||
(loginBody "admin" "s3cret")
|
||
check (hasStatus res 204)
|
||
it "an oversized request with Content-Length gives 413" $
|
||
postWith [("Content-Length", "999999")] "/api/login" (loginBody "admin" "s3cret")
|
||
`shouldRespondWith` 413
|
||
it "a genuinely large body gives 413" $
|
||
jsonRequest methodPost "/api/login" (BL.fromStrict (BS.replicate 70000 97))
|
||
`shouldRespondWith` 413
|
||
|
||
-- | 端到端:真 HTTP + 真 Rust 存储进程
|
||
e2eSpec :: FilePath -> Spec
|
||
e2eSpec staticDir = describe "End-to-end: browser -> Haskell -> Rust" $ do
|
||
it "login -> create table -> insert -> query -> logout all the way through" $ do
|
||
serverBin <- locateServerOrSkip
|
||
started <- startStorageProcess serverBin
|
||
running <- case started of
|
||
Left err -> do
|
||
expectationFailure err
|
||
fail "the storage process did not start"
|
||
Right ok -> pure ok
|
||
bracket (pure running) stopStorageProcess $ \sp -> do
|
||
backend <- ipcBackend
|
||
clock <- newIORef =<< getCurrentTime
|
||
sessions <- newSessionStore (readIORef clock) defaultSessionPolicy
|
||
limiter <- newRateLimiter (readIORef clock) 50 300
|
||
appEnv <- newAppEnv backend sessions testCredential limiter staticDir
|
||
app <- webApp appEnv
|
||
Warp.testWithApplication (pure app) $ \boundPort -> do
|
||
mgr <- newManager defaultManagerSettings
|
||
anon <- e2eGet mgr boundPort "/api/tables" []
|
||
statusCode (responseStatus anon) `shouldBe` 401
|
||
signedIn <- e2ePost mgr boundPort "/api/login" (loginBody "admin" "s3cret") []
|
||
statusCode (responseStatus signedIn) `shouldBe` 204
|
||
let cookie = e2eCookie signedIn
|
||
cookie `shouldSatisfy` maybe False (not . BS.null)
|
||
let auth = [("Cookie", fromMaybe "" cookie)]
|
||
created <- e2ePost mgr boundPort "/api/query" "{\"sql\":\"CREATE TABLE web_e2e (id int, name str)\"}" auth
|
||
statusCode (responseStatus created) `shouldBe` 200
|
||
inserted <-
|
||
e2ePost
|
||
mgr
|
||
boundPort
|
||
"/api/query"
|
||
"{\"sql\":\"INSERT INTO web_e2e (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')\"}"
|
||
auth
|
||
statusCode (responseStatus inserted) `shouldBe` 200
|
||
selected <- e2ePost mgr boundPort "/api/query" "{\"sql\":\"SELECT id, name FROM web_e2e ORDER BY id\"}" auth
|
||
statusCode (responseStatus selected) `shouldBe` 200
|
||
let body = fromMaybe A.Null (decode (responseBody selected))
|
||
asInt (at "rowCount" body) `shouldBe` 3
|
||
nth 2 (items (at "rows" body)) `shouldBe` Just (A.Array (V.fromList [A.Number 3, A.String "c"]))
|
||
listed <- e2eGet mgr boundPort "/api/tables" auth
|
||
statusCode (responseStatus listed) `shouldBe` 200
|
||
let tableNames = map (asText . at "table") (items (fromMaybe A.Null (decode (responseBody listed))))
|
||
tableNames `shouldContain` ["web_e2e"]
|
||
home <- e2eGet mgr boundPort "/" []
|
||
statusCode (responseStatus home) `shouldBe` 200
|
||
BL.toStrict (responseBody home) `shouldSatisfy` BS.isInfixOf "ChuSQL"
|
||
browsed <- e2eGet mgr boundPort "/api/tables/web_e2e/rows?limit=2" auth
|
||
statusCode (responseStatus browsed) `shouldBe` 200
|
||
asInt (at "total" (fromMaybe A.Null (decode (responseBody browsed)))) `shouldBe` 3
|
||
uiCreated <- e2ePost mgr boundPort "/api/tables" "{\"name\":\"web_ui\",\"columns\":[{\"name\":\"id\",\"type\":\"int\"},{\"name\":\"code\",\"type\":\"int\"}]}" auth
|
||
statusCode (responseStatus uiCreated) `shouldBe` 200
|
||
uiIns <- e2ePost mgr boundPort "/api/tables/web_ui/rows" "{\"values\":{\"id\":1,\"code\":5}}" auth
|
||
statusCode (responseStatus uiIns) `shouldBe` 200
|
||
uiIdx <- e2ePost mgr boundPort "/api/tables/web_ui/indexes" "{\"column\":\"code\"}" auth
|
||
statusCode (responseStatus uiIdx) `shouldBe` 200
|
||
uiInfo <- e2eGet mgr boundPort "/api/tables/web_ui" auth
|
||
let uiJson = fromMaybe A.Null (decode (responseBody uiInfo))
|
||
sort (map (asText . at "column") (items (at "indexes" uiJson))) `shouldBe` ["code", "id"]
|
||
map (at "builtIn") (items (at "indexes" uiJson)) `shouldSatisfy` elem (A.Bool True)
|
||
_ <- e2ePost mgr boundPort "/api/tables/web_ui/rows" "{\"values\":{\"id\":2,\"code\":5}}" auth
|
||
dupIdx <- e2ePost mgr boundPort "/api/tables/web_ui/indexes" "{\"column\":\"code\"}" auth
|
||
statusCode (responseStatus dupIdx) `shouldBe` 400
|
||
uiDel <- e2eSend mgr boundPort "DELETE" "/api/tables/web_ui/rows/2" "" auth
|
||
statusCode (responseStatus uiDel) `shouldBe` 200
|
||
uiRows <- e2eGet mgr boundPort "/api/tables/web_ui/rows" auth
|
||
asInt (at "total" (fromMaybe A.Null (decode (responseBody uiRows)))) `shouldBe` 1
|
||
droppedCol <- e2eSend mgr boundPort "DELETE" "/api/tables/web_ui/columns/code" "" auth
|
||
statusCode (responseStatus droppedCol) `shouldBe` 200
|
||
afterDrop <- e2eGet mgr boundPort "/api/tables/web_ui" auth
|
||
let afterJson = fromMaybe A.Null (decode (responseBody afterDrop))
|
||
map (asText . at "name") (items (at "columns" afterJson)) `shouldBe` ["id"]
|
||
map (asText . at "column") (items (at "indexes" afterJson)) `shouldBe` ["id"]
|
||
afterRows <- e2eGet mgr boundPort "/api/tables/web_ui/rows" auth
|
||
let afterRowsJson = fromMaybe A.Null (decode (responseBody afterRows))
|
||
asInt (at "total" afterRowsJson) `shouldBe` 1
|
||
map asText (items (at "columns" afterRowsJson)) `shouldBe` ["id"]
|
||
dropId <- e2eSend mgr boundPort "DELETE" "/api/tables/web_ui/columns/id" "" auth
|
||
statusCode (responseStatus dropId) `shouldBe` 400
|
||
demo <- e2ePost mgr boundPort "/api/demo-data" "{}" auth
|
||
statusCode (responseStatus demo) `shouldBe` 200
|
||
let demoJson = fromMaybe A.Null (decode (responseBody demo))
|
||
arrayLen (at "created" demoJson) `shouldBe` 3
|
||
demoProducts <- e2eGet mgr boundPort "/api/tables/products" auth
|
||
let prodJson = fromMaybe A.Null (decode (responseBody demoProducts))
|
||
map (asText . at "column") (items (at "indexes" prodJson)) `shouldContain` ["sku"]
|
||
demoUsers <- e2eGet mgr boundPort "/api/tables/users/rows?limit=100" auth
|
||
asInt (at "total" (fromMaybe A.Null (decode (responseBody demoUsers)))) `shouldBe` 10
|
||
out <- e2ePost mgr boundPort "/api/logout" "{}" auth
|
||
statusCode (responseStatus out) `shouldBe` 204
|
||
afterLogout <- e2eGet mgr boundPort "/api/tables" auth
|
||
statusCode (responseStatus afterLogout) `shouldBe` 401
|
||
exists <- doesDirectoryExist (spDataDir sp)
|
||
exists `shouldBe` True
|
||
|
||
-- | 找存储进程,cargo 不可用则 pending
|
||
locateServerOrSkip :: IO FilePath
|
||
locateServerOrSkip = do
|
||
found <- firstDir [".." </> "chusql-storage", "chusql-storage"]
|
||
case found of
|
||
Nothing -> do
|
||
expectationFailure "cannot find the chusql-storage directory (tests must run inside the repository)"
|
||
pure ""
|
||
Just storageDir -> do
|
||
built <- buildServer storageDir
|
||
case built of
|
||
Left err
|
||
| "cannot run cargo" `isInfixOf` err -> do
|
||
pendingWith err
|
||
pure ""
|
||
| otherwise -> do
|
||
expectationFailure err
|
||
pure ""
|
||
Right bin -> pure bin
|
||
|
||
-- | 编译 Rust 存储并找可执行文件
|
||
buildServer :: FilePath -> IO (Either String FilePath)
|
||
buildServer storageDir = do
|
||
built <-
|
||
try (createProcess (proc "cargo" ["build", "--bin", "chusql-storage"]){cwd = Just storageDir, std_out = NoStream, std_err = NoStream}) ::
|
||
IO (Either IOException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
|
||
case built of
|
||
Left e -> pure (Left ("cannot run cargo (Rust is required for the IPC tests): " ++ show e))
|
||
Right (_, _, _, ph) -> do
|
||
code <- waitForProcess ph
|
||
case code of
|
||
ExitFailure n -> pure (Left ("cargo build --bin chusql-storage failed with exit code " ++ show n))
|
||
ExitSuccess -> do
|
||
found <-
|
||
firstFile
|
||
[ storageDir </> "target" </> "debug" </> "chusql-storage.exe"
|
||
, storageDir </> "target" </> "debug" </> "chusql-storage"
|
||
]
|
||
pure (maybe (Left "cargo build finished but no storage executable was found") Right found)
|
||
|
||
-- | 找第一个存在的目录
|
||
firstDir :: [FilePath] -> IO (Maybe FilePath)
|
||
firstDir [] = pure Nothing
|
||
firstDir (d : ds) = do
|
||
ok <- doesDirectoryExist d
|
||
if ok then pure (Just d) else firstDir ds
|
||
|
||
-- | 找第一个存在的文件
|
||
firstFile :: [FilePath] -> IO (Maybe FilePath)
|
||
firstFile [] = pure Nothing
|
||
firstFile (p : ps) = do
|
||
ok <- doesFileExist p
|
||
if ok then pure (Just p) else firstFile ps
|
||
|
||
-- | 从响应头里取会话 Cookie
|
||
e2eCookie :: Response BL.ByteString -> Maybe BS.ByteString
|
||
e2eCookie resp = case lookup "Set-Cookie" (responseHeaders resp) of
|
||
Nothing -> Nothing
|
||
Just raw -> Just (BSC.takeWhile (/= ';') raw)
|
||
|
||
-- | 端到端:任意方法的 JSON 请求
|
||
e2eSend :: Manager -> Int -> BS.ByteString -> BS.ByteString -> BL.ByteString -> [Header] -> IO (Response BL.ByteString)
|
||
e2eSend mgr port httpMethod path payload headers = do
|
||
base <- parseRequest ("http://127.0.0.1:" ++ show port ++ BSC.unpack path)
|
||
httpLbs
|
||
base
|
||
{ method = httpMethod
|
||
, requestBody = RequestBodyLBS payload
|
||
, requestHeaders = ("Content-Type", "application/json") : headers
|
||
}
|
||
mgr
|
||
|
||
-- | 端到端 POST
|
||
e2ePost :: Manager -> Int -> BS.ByteString -> BL.ByteString -> [Header] -> IO (Response BL.ByteString)
|
||
e2ePost mgr port path payload headers = e2eSend mgr port "POST" path payload headers
|
||
|
||
-- | 端到端 GET
|
||
e2eGet :: Manager -> Int -> BS.ByteString -> [Header] -> IO (Response BL.ByteString)
|
||
e2eGet mgr port path headers = do
|
||
base <- parseRequest ("http://127.0.0.1:" ++ show port ++ BSC.unpack path)
|
||
httpLbs base{requestHeaders = headers} mgr
|