2026-09-25 17:41:51 +08:00
|
|
|
|
{-# 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
|
2026-09-27 22:21:45 +08:00
|
|
|
|
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}"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 上面那份设置解出来的值
|
|
|
|
|
|
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
|