2026-09-25 17:41:51 +08:00
|
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
2026-10-01 20:48:38 +08:00
|
|
|
|
{-# LANGUAGE PatternSynonyms #-}
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
module Main (main) where
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import ChuSQL.Model (ColumnType (..), Database, Table (..), Value (..), plainColumn, pattern TInt, pattern TStr, pattern TBool)
|
|
|
|
|
|
import ChuSQL.Storage.IPC (Account (..), Request (..), SchemaColumn (..), TableInfo (..), setPipeName)
|
|
|
|
|
|
import ChuSQL.Web.Accounts
|
|
|
|
|
|
import Data.Either (isLeft, isRight)
|
|
|
|
|
|
import ChuSQL.Syntax.AST (Expr (..), FromClause (..), JoinKind (..), Statement (..))
|
|
|
|
|
|
import ChuSQL.Syntax.Parser (parseStatement)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import ChuSQL.Web.Actions (
|
|
|
|
|
|
ColumnSpec (..),
|
|
|
|
|
|
CreateTableSpec (..),
|
|
|
|
|
|
coerceValue,
|
|
|
|
|
|
columnTypeOf,
|
|
|
|
|
|
createIndexSql,
|
|
|
|
|
|
createTableSql,
|
|
|
|
|
|
deleteRowSql,
|
|
|
|
|
|
dropIndexSql,
|
|
|
|
|
|
dropTableSql,
|
|
|
|
|
|
insertRowSql,
|
|
|
|
|
|
insertRowsSql,
|
|
|
|
|
|
isIdentifier,
|
|
|
|
|
|
selectRowsSql,
|
|
|
|
|
|
sqlLiteral,
|
|
|
|
|
|
updateRowSql,
|
|
|
|
|
|
)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import ChuSQL.Web.API (
|
2026-09-25 17:41:51 +08:00
|
|
|
|
AppEnv (..),
|
|
|
|
|
|
Live (..),
|
|
|
|
|
|
defaultLive,
|
|
|
|
|
|
newAppEnv,
|
|
|
|
|
|
parseCookieHeader,
|
|
|
|
|
|
readLive,
|
|
|
|
|
|
renderRow,
|
|
|
|
|
|
setLive,
|
|
|
|
|
|
webApp,
|
|
|
|
|
|
)
|
|
|
|
|
|
import ChuSQL.Web.Auth (
|
|
|
|
|
|
Credential (..),
|
|
|
|
|
|
SessionPolicy (..),
|
|
|
|
|
|
createSession,
|
|
|
|
|
|
defaultSessionPolicy,
|
|
|
|
|
|
deleteSession,
|
|
|
|
|
|
hashLooksValid,
|
|
|
|
|
|
hashPasswordWith,
|
|
|
|
|
|
lookupSession,
|
|
|
|
|
|
newSessionStore,
|
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
|
sessionToken,
|
|
|
|
|
|
setSessionPolicy,
|
|
|
|
|
|
verifyPassword,
|
|
|
|
|
|
)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import ChuSQL.Web.Backend (beAccounts, columnsFromStatement, inDatabase, ipcBackend, memoryBackend, tableInfoOf)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import ChuSQL.Web.Config (
|
|
|
|
|
|
WebConfig (..),
|
2026-10-01 20:48:38 +08:00
|
|
|
|
canonicalSettingKeys,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
defaultPassword,
|
|
|
|
|
|
defaultUser,
|
|
|
|
|
|
defaultWebConfig,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
loadWebConfigAt,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
resolveCredential,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
resolvePipeName,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
usingDefaultCredentials,
|
|
|
|
|
|
)
|
|
|
|
|
|
import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit)
|
|
|
|
|
|
import ChuSQL.Web.Settings (
|
|
|
|
|
|
SettingItem (..),
|
|
|
|
|
|
applySettings,
|
|
|
|
|
|
effectiveSettings,
|
|
|
|
|
|
findItem,
|
|
|
|
|
|
isRestartRequired,
|
|
|
|
|
|
isRootOnly,
|
|
|
|
|
|
liveKeys,
|
|
|
|
|
|
readSettingsFile,
|
|
|
|
|
|
settingCatalogue,
|
|
|
|
|
|
writeSettingsFile,
|
|
|
|
|
|
)
|
|
|
|
|
|
import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import ChuSQL.Web.StorageProcess (StorageProcess (..), platformBinaryName, startStorageProcess, storageChildArgs, stopStorageProcess, waitForStorage)
|
|
|
|
|
|
import ChuSQL.Web.TOML (defaultConfigFile, resolveConfigPath)
|
|
|
|
|
|
import ChuSQL.Web.UISettings (
|
|
|
|
|
|
readUISettings,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
uiSettingsFileCandidates,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings,
|
|
|
|
|
|
writeUISettings,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
)
|
|
|
|
|
|
import Control.Concurrent.MVar (newMVar)
|
|
|
|
|
|
import Control.Exception (IOException, bracket, finally, try)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import Control.Monad (forM_)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import Data.List (isInfixOf, isPrefixOf, nub, partition, sort)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Map.Strict as Map
|
|
|
|
|
|
import Data.Maybe (fromMaybe)
|
|
|
|
|
|
import Data.Text (Text)
|
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
|
import qualified Data.Text.Encoding as TE
|
|
|
|
|
|
import Data.Time.Calendar (fromGregorian)
|
|
|
|
|
|
import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import Data.Unique (hashUnique, newUnique)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Vector as V
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, getXdgDirectory, removePathForcibly)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified System.Directory
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import System.Exit (ExitCode (..))
|
|
|
|
|
|
import System.FilePath ((</>))
|
|
|
|
|
|
import System.IO (Handle)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import System.Info (os)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import System.Process (
|
|
|
|
|
|
CreateProcess (cwd, std_err, std_out),
|
|
|
|
|
|
ProcessHandle,
|
|
|
|
|
|
StdStream (..),
|
|
|
|
|
|
createProcess,
|
|
|
|
|
|
proc,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
readCreateProcessWithExitCode,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
waitForProcess,
|
|
|
|
|
|
)
|
|
|
|
|
|
import Test.Hspec
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import qualified System.Process as Process
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import Test.Hspec.Wai hiding (pendingWith)
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- ChuSQL Web 测试:纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。
|
|
|
|
|
|
|
|
|
|
|
|
-- | 夹具库名:服务不再有默认库,测试世界里这一个库扮演"已选中的库"。
|
|
|
|
|
|
-- 它只是个普通库名(不再是保留名)——保留的只有 system。
|
|
|
|
|
|
testDatabaseName :: String
|
|
|
|
|
|
testDatabaseName = "test"
|
|
|
|
|
|
|
|
|
|
|
|
-- | 夹具库的请求头
|
|
|
|
|
|
testDatabaseHeader :: Header
|
|
|
|
|
|
testDatabaseHeader = ("X-ChuSQL-Database", TE.encodeUtf8 (T.pack testDatabaseName))
|
|
|
|
|
|
|
|
|
|
|
|
-- | 没有显式指定库的请求都落到夹具库;要测"没选库"的用例请直接用 `request`
|
|
|
|
|
|
fixtureHeaders :: [Header] -> [Header]
|
|
|
|
|
|
fixtureHeaders hs
|
|
|
|
|
|
| any ((== "X-ChuSQL-Database") . fst) hs = hs
|
|
|
|
|
|
| otherwise = testDatabaseHeader : hs
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
selectStar from = Select ["*"] from Nothing [] [] Nothing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 造一条显式列名的 SELECT
|
|
|
|
|
|
selectThese :: [String] -> FromClause -> Statement
|
2026-10-01 20:48:38 +08:00
|
|
|
|
selectThese cols from = Select cols from Nothing [] [] Nothing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | with 块里的会话类型别名
|
|
|
|
|
|
type TestSession a = WaiSession () a
|
|
|
|
|
|
|
|
|
|
|
|
-- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录
|
|
|
|
|
|
freshWorld :: FilePath -> IO AppEnv
|
2026-10-01 20:48:38 +08:00
|
|
|
|
freshWorld staticDir = freshWorldWith staticDir testCredential
|
|
|
|
|
|
|
|
|
|
|
|
-- | 同上,但换一个 root 凭据(免密用例用)
|
|
|
|
|
|
freshWorldWith :: FilePath -> Credential -> IO AppEnv
|
|
|
|
|
|
freshWorldWith staticDir credential = do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
clock <- newIORef epoch
|
|
|
|
|
|
sessions <- newSessionStore (readIORef clock) policy
|
|
|
|
|
|
limiter <- newRateLimiter (readIORef clock) 5 (5 * 60)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
env <- newAppEnv (memoryBackend testDatabaseName db) sessions credential limiter staticDir
|
2026-09-25 17:41:51 +08:00
|
|
|
|
path <- tempSettingsPath "default"
|
|
|
|
|
|
removeIfExists path
|
2026-10-01 20:48:38 +08:00
|
|
|
|
uiPath <- tempUISettingsPath "default"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
removeIfExists uiPath
|
2026-10-01 20:48:38 +08:00
|
|
|
|
pure env{aeSettingsFile = path, aeUISettingsFile = uiPath, aeEffective = Map.empty}
|
|
|
|
|
|
|
|
|
|
|
|
-- | 免密管理员的世界:空口令的凭据等价于"只允许管理员免密登录"
|
|
|
|
|
|
withPasswordlessWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec
|
|
|
|
|
|
withPasswordlessWorld staticDir body = do
|
|
|
|
|
|
appEnv <- runIO (freshWorldWith staticDir (Credential "admin" ""))
|
|
|
|
|
|
with (webApp appEnv) (body appEnv)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 临时设置文件(每个用例组一个名字,互不干扰)
|
|
|
|
|
|
tempSettingsPath :: String -> IO FilePath
|
|
|
|
|
|
tempSettingsPath name = do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
pure (tmp </> ("chusql-web-test-" ++ name ++ "-settings.json"))
|
|
|
|
|
|
|
|
|
|
|
|
-- | 临时 IDE 设置文件(同样按用例组取名)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
tempUISettingsPath :: String -> IO FilePath
|
|
|
|
|
|
tempUISettingsPath name = do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
uiPath <- tempUISettingsPath name
|
2026-09-25 17:41:51 +08:00
|
|
|
|
removeIfExists uiPath
|
2026-10-01 20:48:38 +08:00
|
|
|
|
pure env{aeSettingsFile = path, aeUISettingsFile = uiPath}
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 临时静态目录,内容固定便于断言
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 会话令牌变请求头;顺带带上夹具库——服务没有默认库,缺头就是"没选库"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
cookieHeaders :: WT.SResponse -> [Header]
|
|
|
|
|
|
cookieHeaders res = case sessionCookieOf res of
|
|
|
|
|
|
Nothing -> []
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token)), testDatabaseHeader]
|
|
|
|
|
|
|
|
|
|
|
|
-- | 把请求指到系统库 system(账号表与 system 库本身都在那里)
|
|
|
|
|
|
systemHeaders :: [Header] -> [Header]
|
|
|
|
|
|
systemHeaders hs = ("X-ChuSQL-Database", "system") : filter ((/= "X-ChuSQL-Database") . fst) hs
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 带 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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
getWith headers path = request methodGet path (fixtureHeaders headers) ""
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 带请求头的 JSON POST
|
|
|
|
|
|
postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
postWith headers path payload =
|
2026-10-01 20:48:38 +08:00
|
|
|
|
request methodPost path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 带请求头的 JSON PUT(改设置用的是 PUT)
|
|
|
|
|
|
putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
putWith headers path payload =
|
2026-10-01 20:48:38 +08:00
|
|
|
|
request "PUT" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload
|
|
|
|
|
|
|
|
|
|
|
|
-- | 带请求头的 JSON PATCH(改行用的是 PATCH)
|
|
|
|
|
|
patchWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
patchWith headers path payload =
|
|
|
|
|
|
request "PATCH" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload
|
|
|
|
|
|
|
|
|
|
|
|
-- | 带请求头的 DELETE
|
|
|
|
|
|
deleteWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
deleteWith headers path = request "DELETE" path (fixtureHeaders headers) ""
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 在 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
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 状态码断言,失败时把响应体和状态码一起报出来(定位用)
|
|
|
|
|
|
expectStatusWith :: String -> WT.SResponse -> Int -> TestSession ()
|
|
|
|
|
|
expectStatusWith label res code =
|
|
|
|
|
|
check $
|
|
|
|
|
|
if statusCode (WT.simpleStatus res) == code
|
|
|
|
|
|
then pure ()
|
|
|
|
|
|
else
|
|
|
|
|
|
expectationFailure
|
|
|
|
|
|
( label
|
|
|
|
|
|
++ ": expected "
|
|
|
|
|
|
++ show code
|
|
|
|
|
|
++ " but got "
|
|
|
|
|
|
++ show (statusCode (WT.simpleStatus res))
|
|
|
|
|
|
++ " body="
|
|
|
|
|
|
++ show (WT.simpleBody res)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉)
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
staticAssetSpec
|
|
|
|
|
|
staticServeSpec
|
2026-09-25 17:41:51 +08:00
|
|
|
|
authSpec staticDir
|
2026-10-01 20:48:38 +08:00
|
|
|
|
passwordlessSpec staticDir
|
2026-09-25 17:41:51 +08:00
|
|
|
|
catalogSpec staticDir
|
|
|
|
|
|
limitsSpec staticDir
|
|
|
|
|
|
oneClickSpec staticDir
|
|
|
|
|
|
sqlSpec staticDir
|
2026-10-01 20:48:38 +08:00
|
|
|
|
privilegesSpec staticDir
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
describe "Account policy and lifecycle" $ do
|
|
|
|
|
|
it "rejects invalid configurable policy limits" $ do
|
|
|
|
|
|
mapM_ (\(key, value) -> applySettings Map.empty (Map.singleton key value) `shouldSatisfy` isLeft)
|
|
|
|
|
|
[("password-min-length", "7"), ("password-classes", "5")]
|
|
|
|
|
|
it "the admin is configuration only: no storage needed to sign in" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
let backend = (memoryBackend testDatabaseName db){beAccounts = const (pure (Left "unavailable"))}
|
|
|
|
|
|
service <- newAccounts backend sessions testCredential
|
|
|
|
|
|
authenticate service "admin" "s3cret" >>= (`shouldSatisfy` isRight)
|
|
|
|
|
|
listAccounts service >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
it "enforces password length and character classes" $ do
|
|
|
|
|
|
let pol = defaultPasswordPolicy
|
|
|
|
|
|
passwordAllowed pol "tiny" `shouldSatisfy` isLeft
|
|
|
|
|
|
passwordAllowed pol "alllowercaseletters" `shouldSatisfy` isLeft
|
|
|
|
|
|
passwordAllowed pol "long-password" `shouldBe` Right ()
|
|
|
|
|
|
it "the admin name is reserved and ordinary names are validated" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential
|
|
|
|
|
|
runAccountCommand service (Root "admin") (CreateAccount "ADMIN" "another-password") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service (Root "admin") (CreateAccount "with space" "another-password") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service (Root "admin") (CreateAccount "alice" "tiny") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
it "creates, resets and drops ordinary accounts, and a reset revokes their sessions" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential
|
|
|
|
|
|
let admin = Root "admin"
|
|
|
|
|
|
runAccountCommand service admin (CreateAccount "Alice" "alice-password") >>= (`shouldBe` Right ())
|
|
|
|
|
|
Right alice <- authenticate service "alice" "alice-password"
|
|
|
|
|
|
Right bob <- authenticate service "admin" "s3cret"
|
|
|
|
|
|
runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldBe` Right ())
|
|
|
|
|
|
currentPrincipal service alice >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
currentPrincipal service bob >>= (`shouldSatisfy` isRight)
|
|
|
|
|
|
authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isRight)
|
|
|
|
|
|
runAccountCommand service admin (DropAccount "alice") >>= (`shouldBe` Right ())
|
|
|
|
|
|
authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service admin (DropAccount "alice") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
it "an ordinary account cannot administer accounts" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential
|
|
|
|
|
|
let admin = Root "admin"
|
|
|
|
|
|
runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ())
|
|
|
|
|
|
Right token <- authenticate service "alice" "alice-password"
|
|
|
|
|
|
Right principal <- currentPrincipal service token
|
|
|
|
|
|
principalIsRoot principal `shouldBe` False
|
|
|
|
|
|
principalName principal `shouldBe` "alice"
|
|
|
|
|
|
runAccountCommand service principal (CreateAccount "bob" "bob-password") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service principal (DropAccount "alice") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
authenticate service "bob" "bob-password" >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
it "a failed account write keeps the old password" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
let original = memoryBackend testDatabaseName db
|
|
|
|
|
|
backend = original{beAccounts = \req -> case req of
|
|
|
|
|
|
ReqAccountReset {} -> pure (Left "write failed")
|
|
|
|
|
|
_ -> beAccounts original req}
|
|
|
|
|
|
service <- newAccounts backend sessions testCredential
|
|
|
|
|
|
let admin = Root "admin"
|
|
|
|
|
|
runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ())
|
|
|
|
|
|
runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isRight)
|
|
|
|
|
|
authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
it "a legacy row named like the administrator can be dropped, but not recreated" $ do
|
|
|
|
|
|
db <- newMVar testDb
|
|
|
|
|
|
sessions <- newSessionStore (pure epoch) policy
|
|
|
|
|
|
_ <- beAccounts (memoryBackend testDatabaseName db) (ReqAccountCreate "admin" "legacy-hash")
|
|
|
|
|
|
service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential
|
|
|
|
|
|
runAccountCommand service (Root "admin") (CreateAccount "admin" "another-password") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service (Root "admin") (ResetAccountPassword "admin" "another-password") >>= (`shouldSatisfy` isLeft)
|
|
|
|
|
|
runAccountCommand service (Root "admin") (DropAccount "admin") >>= (`shouldBe` Right ())
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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 "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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
let pairs = [(siSection i, siTomlKey i) | i <- settingCatalogue]
|
|
|
|
|
|
[s | (s, _) <- pairs] `shouldSatisfy` all (not . T.null)
|
|
|
|
|
|
[k | (_, k) <- pairs] `shouldSatisfy` all (not . T.null)
|
|
|
|
|
|
length pairs `shouldBe` length (nub pairs)
|
|
|
|
|
|
findItem "pipe-name" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("server", "pipe_name"))
|
|
|
|
|
|
findItem "storage-page-size" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("page", "size"))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "critical settings are root-only, tuning ones are not" $ do
|
|
|
|
|
|
isRootOnly "port" `shouldBe` True
|
|
|
|
|
|
isRootOnly "host" `shouldBe` True
|
|
|
|
|
|
isRootOnly "user" `shouldBe` True
|
2026-10-01 20:48:38 +08:00
|
|
|
|
isRootOnly "password" `shouldBe` True
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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 "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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "the config file lives at the fixed place the installer writes" $ do
|
|
|
|
|
|
fixed <- defaultConfigFile
|
|
|
|
|
|
dir <- getXdgDirectory XdgConfig "ChuSQL"
|
|
|
|
|
|
fixed `shouldBe` (dir </> "chusql.toml")
|
|
|
|
|
|
it "an explicit --config path wins over the fixed place" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
tmp <- getTemporaryDirectory
|
2026-10-01 20:48:38 +08:00
|
|
|
|
let explicit = tmp </> "chusql-web-test-explicit.toml"
|
|
|
|
|
|
resolveConfigPath (Just explicit) `shouldReturn` explicit
|
|
|
|
|
|
it "without --config the fixed place is used" $ do
|
|
|
|
|
|
fixed <- defaultConfigFile
|
|
|
|
|
|
resolveConfigPath Nothing `shouldReturn` fixed
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "normalizes default qualified headers" $ do
|
|
|
|
|
|
fmap (columnsFromStatement testDb) (parseStatement "SELECT users.name FROM users WHERE FALSE") `shouldBe` Right ["name"]
|
|
|
|
|
|
it "infers headers for a SELECT without FROM" $ do
|
|
|
|
|
|
fmap (columnsFromStatement testDb) (parseStatement "SELECT 1 + 2 LIMIT 0") `shouldBe` Right ["1 + 2"]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
(selectStar (FromJoin InnerJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "users.id") (Col "o.user_id"))))
|
|
|
|
|
|
`shouldBe` ["users.id", "users.name", "users.age", "o.id", "o.user_id"]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)]
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
describe "Database qualification (inDatabase)" $ do
|
|
|
|
|
|
let rewrite db sql = parseStatement sql >>= Right . inDatabase db
|
|
|
|
|
|
|
|
|
|
|
|
it "prefixes the tables of a subquery as well" $ do
|
|
|
|
|
|
case rewrite "sales" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of
|
|
|
|
|
|
Left err -> expectationFailure err
|
|
|
|
|
|
Right stmt -> do
|
|
|
|
|
|
let text = show stmt
|
|
|
|
|
|
("sales.users" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("sales.orders" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("\"orders\"" `isInfixOf` text) `shouldBe` False
|
|
|
|
|
|
|
|
|
|
|
|
it "keeps the JOIN kind while prefixing both sides" $ do
|
|
|
|
|
|
case rewrite "sales" "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id" of
|
|
|
|
|
|
Left err -> expectationFailure err
|
|
|
|
|
|
Right stmt -> do
|
|
|
|
|
|
let text = show stmt
|
|
|
|
|
|
("LeftJoin" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("sales.users" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("sales.orders" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("\"orders\"" `isInfixOf` text) `shouldBe` False
|
|
|
|
|
|
|
|
|
|
|
|
it "qualifies a subquery that sits inside a JOIN condition" $ do
|
|
|
|
|
|
case rewrite "sales" "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id AND EXISTS (SELECT 1 FROM orders x WHERE x.user_id = u.id)" of
|
|
|
|
|
|
Left err -> expectationFailure err
|
|
|
|
|
|
Right stmt -> do
|
|
|
|
|
|
let text = show stmt
|
|
|
|
|
|
("sales.users" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("sales.orders" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("\"orders\"" `isInfixOf` text) `shouldBe` False
|
|
|
|
|
|
|
|
|
|
|
|
it "leaves table names alone when no database is selected" $ do
|
|
|
|
|
|
case rewrite "" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of
|
|
|
|
|
|
Left err -> expectationFailure err
|
|
|
|
|
|
Right stmt -> do
|
|
|
|
|
|
let text = show stmt
|
|
|
|
|
|
("\"users\"" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("\"orders\"" `isInfixOf` text) `shouldBe` True
|
|
|
|
|
|
("test." `isInfixOf` text) `shouldBe` False
|
|
|
|
|
|
|
|
|
|
|
|
describe "Administrator credential source (settings file only)" $ do
|
|
|
|
|
|
it "with nothing configured the built-in demo account is used" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
defaultUser `shouldBe` "root"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
usingDefaultCredentials Map.empty `shouldBe` True
|
|
|
|
|
|
cred <- resolveCredential defaultWebConfig Map.empty
|
2026-09-25 17:41:51 +08:00
|
|
|
|
credUser cred `shouldBe` defaultUser
|
|
|
|
|
|
verifyPassword (credEncoded cred) defaultPassword `shouldBe` True
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "a plain password in the settings file wins" $ do
|
|
|
|
|
|
let saved = Map.singleton "password" "s3cret"
|
|
|
|
|
|
usingDefaultCredentials saved `shouldBe` False
|
|
|
|
|
|
cred <- resolveCredential defaultWebConfig saved
|
2026-09-25 17:41:51 +08:00
|
|
|
|
verifyPassword (credEncoded cred) "s3cret" `shouldBe` True
|
|
|
|
|
|
verifyPassword (credEncoded cred) defaultPassword `shouldBe` False
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "an explicitly empty password makes the administrator passwordless" $ do
|
|
|
|
|
|
let saved = Map.singleton "password" ""
|
|
|
|
|
|
usingDefaultCredentials saved `shouldBe` False
|
|
|
|
|
|
cred <- resolveCredential defaultWebConfig saved
|
|
|
|
|
|
credUser cred `shouldBe` defaultUser
|
|
|
|
|
|
credEncoded cred `shouldBe` ""
|
|
|
|
|
|
-- 空口令不是"能匹配任意口令"的哈希:它是一个明确的空编码串
|
|
|
|
|
|
verifyPassword (credEncoded cred) "" `shouldBe` False
|
|
|
|
|
|
it "the retired password-hash key is not a setting and never a credential" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
cred <- resolveCredential defaultWebConfig (Map.singleton "password-hash" encoded)
|
|
|
|
|
|
verifyPassword (credEncoded cred) defaultPassword `shouldBe` True
|
|
|
|
|
|
applySettings Map.empty (Map.singleton "password-hash" encoded) `shouldSatisfy` isLeftE
|
|
|
|
|
|
it "the administrator name comes from the file, and --user still wins" $ do
|
|
|
|
|
|
cred <- resolveCredential defaultWebConfig (Map.fromList [("user", "tester"), ("password", "s3cret")])
|
2026-09-25 17:41:51 +08:00
|
|
|
|
credUser cred `shouldBe` "tester"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
fromCli <- resolveCredential defaultWebConfig{wcUser = "from-cli"} (Map.singleton "user" "tester")
|
|
|
|
|
|
credUser fromCli `shouldBe` "from-cli"
|
|
|
|
|
|
it "the password environment variables are no longer consulted" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let missing = tmp </> "chusql-web-test-cred-env.toml"
|
|
|
|
|
|
removeIfExists missing
|
|
|
|
|
|
withEnv [("CHUSQL_WEB_PASSWORD", "from-env"), ("CHUSQL_WEB_USER", "env-user")] $ do
|
|
|
|
|
|
cfg <- loadWebConfigAt missing
|
|
|
|
|
|
wcUser cfg `shouldBe` ""
|
|
|
|
|
|
cred <- resolveCredential cfg Map.empty
|
|
|
|
|
|
credUser cred `shouldBe` defaultUser
|
|
|
|
|
|
verifyPassword (credEncoded cred) "from-env" `shouldBe` False
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
describe "Config: built-in defaults" $ do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "port defaults to 7777 and the administrator name is not baked into the config" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
wcPort defaultWebConfig `shouldBe` 7777
|
|
|
|
|
|
wcHost defaultWebConfig `shouldBe` "127.0.0.1"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
wcUser defaultWebConfig `shouldBe` ""
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
describe "Config: environment variables are ignored (the file comes from --config)" $ do
|
|
|
|
|
|
it "port / host / static dir / limits in the environment change nothing" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let missing = tmp </> "chusql-web-test-ignored-env.toml"
|
|
|
|
|
|
removeIfExists missing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
withEnv
|
|
|
|
|
|
[ ("CHUSQL_WEB_PORT", "8123")
|
|
|
|
|
|
, ("CHUSQL_WEB_HOST", "0.0.0.0")
|
|
|
|
|
|
, ("CHUSQL_WEB_STATIC", "mysite")
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, ("CHUSQL_WEB_SESSION_IDLE", "60")
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, ("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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
cfg <- loadWebConfigAt missing
|
|
|
|
|
|
cfg `shouldBe` defaultWebConfig
|
|
|
|
|
|
it "the retired endpoint variables change nothing either" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let missing = tmp </> "chusql-web-test-ignored-endpoint.toml"
|
|
|
|
|
|
removeIfExists missing
|
|
|
|
|
|
withEnv
|
|
|
|
|
|
[ ("CHUSQL_PIPE", "from-env-pipe")
|
|
|
|
|
|
, ("CHUSQL_DATA_DIR", "from-env-data")
|
|
|
|
|
|
, ("CHUSQL_STORAGE_SERVER", "from-env-server")
|
|
|
|
|
|
]
|
|
|
|
|
|
$ do
|
|
|
|
|
|
cfg <- loadWebConfigAt missing
|
|
|
|
|
|
wcPipeName cfg `shouldBe` Nothing
|
|
|
|
|
|
wcDataDir cfg `shouldBe` Nothing
|
|
|
|
|
|
wcStorageServer cfg `shouldBe` Nothing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
describe "Config: the sections of the global chusql.toml" $ do
|
|
|
|
|
|
it "reads the section and accepts both underscore and hyphen spellings" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let path = tmp </> "chusql-web-test-global.toml"
|
|
|
|
|
|
writeFile path "[page]\nsize = 8192\n\n[web]\nstatic_dir = \"webroot\"\nsession_idle = 4242\nrows_per_page = 33\n"
|
|
|
|
|
|
cfg <- loadWebConfigAt path
|
|
|
|
|
|
wcStaticDir cfg `shouldBe` "webroot"
|
|
|
|
|
|
wcSessionIdle cfg `shouldBe` 4242
|
|
|
|
|
|
wcPageSize cfg `shouldBe` 33
|
|
|
|
|
|
-- 别的层的分区不归 web 管,没给的键回到内置默认
|
|
|
|
|
|
wcPort cfg `shouldBe` 7777
|
|
|
|
|
|
wcMaxRows cfg `shouldBe` 1000
|
|
|
|
|
|
saved <- readSettingsFile path
|
|
|
|
|
|
Map.lookup "rows-per-page" saved `shouldBe` Just "33"
|
|
|
|
|
|
it "the endpoint comes from [server] pipe_name and [storage] data_dir, the executable from [web] storage_server" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let path = tmp </> "chusql-web-test-endpoint.toml"
|
|
|
|
|
|
writeFile
|
|
|
|
|
|
path
|
|
|
|
|
|
"[server]\npipe_name = \"joint-pipe\"\n\n[storage]\ndata_dir = \"../localdata\"\n\n[web]\nstorage_server = \"chusql-storage.exe\"\n"
|
|
|
|
|
|
cfg <- loadWebConfigAt path
|
|
|
|
|
|
wcPipeName cfg `shouldBe` Just "joint-pipe"
|
|
|
|
|
|
resolvePipeName cfg `shouldBe` "joint-pipe"
|
|
|
|
|
|
wcDataDir cfg `shouldBe` Just "../localdata"
|
|
|
|
|
|
wcStorageServer cfg `shouldBe` Just "chusql-storage.exe"
|
|
|
|
|
|
it "without a file the pipe name falls back to the shared default" $ do
|
|
|
|
|
|
resolvePipeName defaultWebConfig `shouldBe` "chusql-joint"
|
|
|
|
|
|
it "a port in the environment no longer wins over the file" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let path = tmp </> "chusql-web-test-global-env.toml"
|
|
|
|
|
|
writeFile path "[web]\nport = 1234\nhost = \"0.0.0.0\"\n"
|
|
|
|
|
|
withEnv [("CHUSQL_WEB_PORT", "4321")] $ do
|
|
|
|
|
|
cfg <- loadWebConfigAt path
|
|
|
|
|
|
wcPort cfg `shouldBe` 1234
|
|
|
|
|
|
wcHost cfg `shouldBe` "0.0.0.0"
|
|
|
|
|
|
it "the canonical hyphen key wins when both spellings are present" $ do
|
|
|
|
|
|
let both = Map.fromList [("session-idle", "1"), ("session_idle", "2")]
|
|
|
|
|
|
Map.lookup "session-idle" (canonicalSettingKeys both) `shouldBe` Just "1"
|
|
|
|
|
|
it "a missing config file falls back to the built-in defaults" $ do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let missing = tmp </> "chusql-web-test-missing-config.toml"
|
|
|
|
|
|
removeIfExists missing
|
|
|
|
|
|
cfg <- loadWebConfigAt missing
|
|
|
|
|
|
wcPort cfg `shouldBe` wcPort defaultWebConfig
|
|
|
|
|
|
wcPageSize cfg `shouldBe` wcPageSize defaultWebConfig
|
|
|
|
|
|
|
|
|
|
|
|
describe "Spawning the storage process" $ do
|
|
|
|
|
|
it "points the child at the same config file on its command line" $ do
|
|
|
|
|
|
storageChildArgs ("scripts" </> "chusql.toml")
|
|
|
|
|
|
`shouldBe` ["--config", "scripts" </> "chusql.toml"]
|
|
|
|
|
|
it "passes nothing else" $ do
|
|
|
|
|
|
storageChildArgs "chusql.toml" `shouldBe` ["--config", "chusql.toml"]
|
|
|
|
|
|
|
|
|
|
|
|
describe "Installer scripts (scripts/)" $ do
|
|
|
|
|
|
it "install.sh releases files, writes chusql.toml, sets PATH and removes the package" $ do
|
|
|
|
|
|
installer <- readUtf8 (".." </> "scripts" </> "install.sh")
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "chusql-storage"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "chusql.toml"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "PATH"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "rm -rf"
|
|
|
|
|
|
-- 一份脚本三种来源:包内(旁边有 bin/)、在线(管道进来时 $0 是 sh,按平台取 release 资源)、
|
|
|
|
|
|
-- 显式归档或源码。去掉任何一条,从 GitHub 装的用法就断了。
|
|
|
|
|
|
it "install.sh picks the package by platform and can fall back to source" $ do
|
|
|
|
|
|
installer <- readUtf8 (".." </> "scripts" </> "install.sh")
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "bin/chusql-storage"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "uname -s"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "uname -m"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "releases/latest/download"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "chusql-$component-$label.$ext"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf ".sha256"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "--url"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "--from-source"
|
|
|
|
|
|
installer `shouldSatisfy` T.isInfixOf "stack build --fast"
|
|
|
|
|
|
-- static_dir 是相对路径,启动器必须先切到安装目录;少了这一句,从别处执行
|
|
|
|
|
|
-- csql-web 就会因为找不到 static/ 直接退出(Windows 的 .ps1 靠 -WorkingDirectory)。
|
|
|
|
|
|
it "csql-web.sh switches to the install dir before starting" $ do
|
|
|
|
|
|
launcher <- readUtf8 (".." </> "scripts" </> "csql-web.sh")
|
|
|
|
|
|
launcher `shouldSatisfy` T.isInfixOf "cd \"$home_dir\""
|
|
|
|
|
|
launcher `shouldSatisfy` T.isInfixOf "chusql-storage"
|
|
|
|
|
|
-- 默认数据目录按平台惯例算,四处必须说同一件事:Rust 代码、配置示例、模板、文档。
|
|
|
|
|
|
-- 跟安装脚本装的位置也是一处:Windows 装到 %LOCALAPPDATA%\ChuSQL、Unix 装到 ~/.local/share/chusql,
|
|
|
|
|
|
-- 数据都放在它下面的 data/ 里。
|
|
|
|
|
|
it "the default data dir follows the platform, and code, template and docs agree" $ do
|
|
|
|
|
|
rust <- readUtf8 (".." </> "chusql-storage" </> "src" </> "config.rs")
|
|
|
|
|
|
rust `shouldSatisfy` T.isInfixOf "default_data_dir"
|
|
|
|
|
|
rust `shouldSatisfy` T.isInfixOf "LOCALAPPDATA"
|
|
|
|
|
|
rust `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME"
|
|
|
|
|
|
rust `shouldSatisfy` (not . T.isInfixOf "DEFAULT_DATA_DIR")
|
|
|
|
|
|
|
|
|
|
|
|
example <- readUtf8 (".." </> "chusql-storage" </> "chusql-storage.toml.example")
|
|
|
|
|
|
-- 示例文件说自己列的值就是默认值:data_dir 不能是能生效的一行
|
|
|
|
|
|
example `shouldSatisfy` (not . T.isInfixOf "\ndata_dir =")
|
|
|
|
|
|
example `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%"
|
|
|
|
|
|
example `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME"
|
|
|
|
|
|
|
|
|
|
|
|
template <- readUtf8 (".." </> "scripts" </> "chusql.toml")
|
|
|
|
|
|
-- 模板那行是安装时被 sed / -replace 替换的占位,必须保持能生效
|
|
|
|
|
|
template `shouldSatisfy` T.isInfixOf "\ndata_dir = "
|
|
|
|
|
|
template `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%"
|
|
|
|
|
|
|
|
|
|
|
|
configDoc <- readUtf8 (".." </> "doc" </> "config.md")
|
|
|
|
|
|
configDoc `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%\\ChuSQL\\data"
|
|
|
|
|
|
configDoc `shouldSatisfy` T.isInfixOf "chusql/data"
|
|
|
|
|
|
-- 端点规则两侧必须一致:Rust 服务端和 Haskell 前端算出来的必须是同一个路径。
|
|
|
|
|
|
it "Rust and Haskell agree on where the Unix socket lives" $ do
|
|
|
|
|
|
rustEndpoint <- readUtf8 (".." </> "chusql-storage" </> "src" </> "endpoint.rs")
|
|
|
|
|
|
haskellIpc <- readUtf8 (".." </> "chusql-engine" </> "src" </> "ChuSQL" </> "Storage" </> "IPC.hs")
|
|
|
|
|
|
mapM_
|
|
|
|
|
|
( \src -> do
|
|
|
|
|
|
src `shouldSatisfy` T.isInfixOf "XDG_RUNTIME_DIR"
|
|
|
|
|
|
src `shouldSatisfy` T.isInfixOf "TMPDIR"
|
|
|
|
|
|
src `shouldSatisfy` T.isInfixOf "/tmp"
|
|
|
|
|
|
src `shouldSatisfy` T.isInfixOf ".sock"
|
|
|
|
|
|
)
|
|
|
|
|
|
[rustEndpoint, haskellIpc]
|
|
|
|
|
|
it "the Linux pipeline builds both toolchains and runs every test suite" $ do
|
|
|
|
|
|
pipeline <- readUtf8 (".." </> ".github" </> "workflows" </> "linux.yml")
|
|
|
|
|
|
pipeline `shouldSatisfy` T.isInfixOf "ubuntu-latest"
|
|
|
|
|
|
pipeline `shouldSatisfy` T.isInfixOf "cargo test --release"
|
|
|
|
|
|
pipeline `shouldSatisfy` T.isInfixOf "cargo clippy"
|
|
|
|
|
|
pipeline `shouldSatisfy` T.isInfixOf "stack test --fast"
|
|
|
|
|
|
-- 打包与安装冒烟只在发版流水线里做(tag 触发 / 手动 dispatch),这条流水线不再出包
|
|
|
|
|
|
pipeline `shouldSatisfy` (not . T.isInfixOf "package.ps1")
|
|
|
|
|
|
pipeline `shouldSatisfy` (not . T.isInfixOf "smoke-linux.sh")
|
|
|
|
|
|
-- 仓库里没有 rustfmt.toml,历史代码不是按当前 rustfmt 排的:格式化不是门禁
|
|
|
|
|
|
pipeline `shouldSatisfy` (not . T.isInfixOf "cargo fmt")
|
|
|
|
|
|
-- 装的人不该被要求装两个工具链:发版流水线出预编译包,install.sh 按同一套平台标签去取。
|
|
|
|
|
|
-- 装配与打归档原来在 scripts/package.ps1 里,脚本删掉后直接写在流水线里(两个平台各一段)。
|
|
|
|
|
|
it "the release pipeline publishes one archive per platform, tagged the way install.sh looks it up" $ do
|
|
|
|
|
|
release <- readUtf8 (".." </> ".github" </> "workflows" </> "release.yml")
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "linux-x86_64"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "macos-arm64"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "macos-x86_64"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "windows-x86_64"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "cargo build --release"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-web:exe:chusql-web"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-cli:exe:csql"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "tar -czf"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "Compress-Archive"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "scripts/install.sh"
|
|
|
|
|
|
release `shouldSatisfy` (not . T.isInfixOf "package.ps1")
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "sha256"
|
|
|
|
|
|
release `shouldSatisfy` T.isInfixOf "gh release upload"
|
|
|
|
|
|
it "the storage binary name follows the platform (a stray .exe must not win on Linux)" $ do
|
|
|
|
|
|
platformBinaryName "mingw32" "chusql-storage" `shouldBe` "chusql-storage.exe"
|
|
|
|
|
|
platformBinaryName "linux" "chusql-storage" `shouldBe` "chusql-storage"
|
|
|
|
|
|
-- 同一 target 目录里可能躺着一份别处交叉编译出来的 .exe:谁都不许再写
|
|
|
|
|
|
-- 「.exe 优先、无后缀兜底」这种候选表
|
|
|
|
|
|
engine <- readUtf8 (".." </> "chusql-engine" </> "test" </> "Spec.hs")
|
|
|
|
|
|
bench <- readUtf8 (".." </> "benchmark" </> "src" </> "Main.hs")
|
|
|
|
|
|
storage <- readUtf8 ("src" </> "ChuSQL" </> "Web" </> "StorageProcess.hs")
|
|
|
|
|
|
mapM_
|
|
|
|
|
|
(\src -> src `shouldSatisfy` (not . T.isInfixOf "\"chusql-storage.exe\", \"chusql-storage\""))
|
|
|
|
|
|
[engine, bench, storage]
|
|
|
|
|
|
|
|
|
|
|
|
-- 细节分到 doc/ 下四份,README 只留面向用户的关键内容;链接断了或者文档没了,等于没写。
|
|
|
|
|
|
describe "Documentation (doc/)" $ do
|
|
|
|
|
|
it "the README keeps only the essentials and links the split documents" $ do
|
|
|
|
|
|
readme <- readUtf8 (".." </> "README.md")
|
|
|
|
|
|
mapM_
|
|
|
|
|
|
(\target -> readme `shouldSatisfy` T.isInfixOf ("doc/" <> target <> ".md"))
|
|
|
|
|
|
["install", "config", "commands", "architecture"]
|
|
|
|
|
|
-- 安装选项与配置键的长表已经搬走,不该在 README 里再长回来
|
|
|
|
|
|
readme `shouldSatisfy` (not . T.isInfixOf "--from-source")
|
|
|
|
|
|
readme `shouldSatisfy` (not . T.isInfixOf "pool_size")
|
|
|
|
|
|
-- 仓库布局这类开发者信息属于 doc/architecture.md,不属于 README
|
|
|
|
|
|
readme `shouldSatisfy` (not . T.isInfixOf "chusql-engine/")
|
|
|
|
|
|
it "each split document covers what its name promises" $ do
|
|
|
|
|
|
let expectations =
|
|
|
|
|
|
[ ("install", ["install.sh", "install.ps1", "--component", "--install-dir", "--from-source", "rm -rf"])
|
|
|
|
|
|
, ("config", ["[web]", "[page]", "pipe_name", "data_dir", "host", "port"])
|
|
|
|
|
|
, ("commands", ["csql", "--format", "\\dt", "CREATE ROLE", "GRANT", "/api/roles"])
|
|
|
|
|
|
, ("architecture", ["chusql-storage", "chusql-web", "csql", "pipe_name", "data_dir", "CREATE DATABASE"])
|
|
|
|
|
|
]
|
|
|
|
|
|
mapM_
|
|
|
|
|
|
( \(name, needles) -> do
|
|
|
|
|
|
doc <- readUtf8 (".." </> "doc" </> name <> ".md")
|
|
|
|
|
|
mapM_ (\needle -> doc `shouldSatisfy` T.isInfixOf needle) needles
|
|
|
|
|
|
)
|
|
|
|
|
|
expectations
|
|
|
|
|
|
-- doc/ 只写面向用户的内容:构建、测试、打包这些开发流程不进文档
|
|
|
|
|
|
it "the split documents stay user-facing (no build, test or packaging recipes)" $ do
|
|
|
|
|
|
let developerOnly =
|
|
|
|
|
|
[ "package.ps1"
|
|
|
|
|
|
, "stack build"
|
|
|
|
|
|
, "stack test"
|
|
|
|
|
|
, "cargo build"
|
|
|
|
|
|
, "cargo test"
|
|
|
|
|
|
, "cargo clippy"
|
|
|
|
|
|
, ".stack-work"
|
|
|
|
|
|
, ".github/workflows"
|
|
|
|
|
|
, "smoke-linux.sh"
|
|
|
|
|
|
]
|
|
|
|
|
|
mapM_
|
|
|
|
|
|
( \name -> do
|
|
|
|
|
|
doc <- readUtf8 (".." </> "doc" </> name <> ".md")
|
|
|
|
|
|
mapM_ (\needle -> doc `shouldSatisfy` (not . T.isInfixOf needle)) developerOnly
|
|
|
|
|
|
)
|
|
|
|
|
|
["install", "config", "commands", "architecture"]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
staticSpec :: FilePath -> Spec
|
|
|
|
|
|
staticSpec staticDir = describe "Static files" $ do
|
|
|
|
|
|
it "an unreadable file gives Nothing" $
|
|
|
|
|
|
readStatic staticDir "nope.js" `shouldReturn` Nothing
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 手写静态前端:源文件入库,没有打包器,也不该再有 Node 工具链
|
|
|
|
|
|
staticAssetSpec :: Spec
|
|
|
|
|
|
staticAssetSpec = describe "Static front end (hand-written, no bundler)" $ do
|
|
|
|
|
|
let dir = "static"
|
|
|
|
|
|
asset name = dir </> name
|
2026-09-25 17:41:51 +08:00
|
|
|
|
readAsset name = readUtf8 (asset name)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "the app shell is present and loads both assets from /static/" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
html <- readAsset "index.html"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
html `shouldSatisfy` T.isInfixOf "id=\"root\""
|
2026-09-25 17:41:51 +08:00
|
|
|
|
html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\""
|
|
|
|
|
|
html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\""
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- 服务端把 nonce 元信息插在 </head> 前面,这个标记不能丢
|
|
|
|
|
|
html `shouldSatisfy` T.isInfixOf "</head>"
|
|
|
|
|
|
it "the shell has no inline script or style (the CSP allows neither)" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
html <- readAsset "index.html"
|
|
|
|
|
|
html `shouldSatisfy` (not . T.isInfixOf "<script>")
|
|
|
|
|
|
html `shouldSatisfy` (not . T.isInfixOf "<style")
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "no generated template keeps an inline style attribute (the CSP would block it)" $ do
|
|
|
|
|
|
js <- readAsset "app.js"
|
|
|
|
|
|
js `shouldSatisfy` (not . T.isInfixOf "style=\"")
|
|
|
|
|
|
it "the hand-written modules and the stylesheet are non-empty" $
|
|
|
|
|
|
mapM_ (\name -> BS.readFile (asset name) >>= (`shouldSatisfy` (not . BS.null)))
|
|
|
|
|
|
["app.js", "core.js", "api.js", "style.css"]
|
|
|
|
|
|
it "the app carries the Chinese UI copy and the REST client keeps every endpoint" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
js <- readAsset "app.js"
|
|
|
|
|
|
js `shouldSatisfy` T.isInfixOf "待提交变更"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
js `shouldSatisfy` T.isInfixOf "新建查询"
|
|
|
|
|
|
api <- readAsset "api.js"
|
|
|
|
|
|
api `shouldSatisfy` T.isInfixOf "X-ChuSQL-Database"
|
|
|
|
|
|
api `shouldSatisfy` T.isInfixOf "/api/tables/"
|
|
|
|
|
|
api `shouldSatisfy` T.isInfixOf "/api/ui-settings"
|
|
|
|
|
|
api `shouldSatisfy` T.isInfixOf "/api/query"
|
|
|
|
|
|
it "every shipped asset passes the static file name whitelist" $ do
|
|
|
|
|
|
names <- System.Directory.listDirectory dir
|
2026-09-25 17:41:51 +08:00
|
|
|
|
mapM_ (\name -> safeRelative (T.pack name) `shouldBe` Just name) names
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "content types are mapped for every shipped asset" $ do
|
|
|
|
|
|
contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "app.js" `shouldBe` "text/javascript; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "core.js" `shouldBe` "text/javascript; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "api.js" `shouldBe` "text/javascript; charset=utf-8"
|
|
|
|
|
|
contentTypeOf "style.css" `shouldBe` "text/css; charset=utf-8"
|
|
|
|
|
|
it "the Node toolchain is gone from the repository" $ do
|
|
|
|
|
|
frontendDir <- doesDirectoryExist (".." </> "chusql-web" </> "frontend")
|
|
|
|
|
|
nodeModules <- doesDirectoryExist (".." </> "chusql-web" </> "node_modules")
|
|
|
|
|
|
viteConfig <- doesFileExist (".." </> "chusql-web" </> "frontend" </> "vite.config.ts")
|
|
|
|
|
|
packageJson <- doesFileExist (".." </> "chusql-web" </> "frontend" </> "package.json")
|
|
|
|
|
|
frontendDir `shouldBe` False
|
|
|
|
|
|
nodeModules `shouldBe` False
|
|
|
|
|
|
viteConfig `shouldBe` False
|
|
|
|
|
|
packageJson `shouldBe` False
|
|
|
|
|
|
|
|
|
|
|
|
-- | 真静态目录里的资源由服务端交付(临时目录那套在 securitySpec 里另测)
|
|
|
|
|
|
staticServeSpec :: Spec
|
|
|
|
|
|
staticServeSpec = describe "Static front end served by the server" $
|
|
|
|
|
|
withWorld "static" $ \_ -> do
|
|
|
|
|
|
it "the home page carries the app root and the per-page nonce meta" $ do
|
|
|
|
|
|
res <- get "/"
|
|
|
|
|
|
check (hasStatus res 200)
|
|
|
|
|
|
let body = BL.toStrict (WT.simpleBody res)
|
|
|
|
|
|
check (body `shouldSatisfy` BS.isInfixOf "id=\"root\"")
|
|
|
|
|
|
check (body `shouldSatisfy` BS.isInfixOf "chusql-style-nonce")
|
|
|
|
|
|
it "the app script and stylesheet are served with their content types" $ do
|
|
|
|
|
|
js <- get "/static/app.js"
|
|
|
|
|
|
check (hasStatus js 200)
|
|
|
|
|
|
check (lookup "Content-Type" (WT.simpleHeaders js) `shouldBe` Just "text/javascript; charset=utf-8")
|
|
|
|
|
|
css <- get "/static/style.css"
|
|
|
|
|
|
check (hasStatus css 200)
|
|
|
|
|
|
check (lookup "Content-Type" (WT.simpleHeaders css) `shouldBe` Just "text/css; charset=utf-8")
|
|
|
|
|
|
it "traversal and unknown files are still rejected" $ do
|
|
|
|
|
|
get "/static/..%2fchusql.toml" `shouldRespondWith` 404
|
|
|
|
|
|
get "/static/nope.js" `shouldRespondWith` 404
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
createTableSql (CreateTableSpec "t" [ColumnSpec "id" "blob"])
|
|
|
|
|
|
`shouldBe` Right "CREATE TABLE t (id blob)"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "column type parsing: int/bigint/smallint/str/text/varchar/char/float/double/decimal/date/timestamp/bool/blob are recognised" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
map columnTypeOf ["int", "INTEGER", "str", "text", "varchar", "bool", "boolean"]
|
|
|
|
|
|
`shouldBe` map Just [TInt, TInt, TStr, TStr, TStr, TBool, TBool]
|
2026-10-01 20:48:38 +08:00
|
|
|
|
columnTypeOf "blob" `shouldBe` Just (plainColumn CBlob)
|
|
|
|
|
|
-- 类型带参数时才认得出来,未知类型与空串都给 Nothing
|
|
|
|
|
|
map columnTypeOf ["bigint", "smallint", "float", "double", "date", "timestamp"]
|
|
|
|
|
|
`shouldBe` map Just [plainColumn CBigInt, plainColumn CSmallInt, plainColumn CFloat, plainColumn CDouble, plainColumn CDate, plainColumn CTimestamp]
|
|
|
|
|
|
map columnTypeOf ["varchar(20)", "char(3)", "decimal(8,2)"]
|
|
|
|
|
|
`shouldBe` map Just [plainColumn (CVarchar 20), plainColumn (CChar 3), plainColumn (CDecimal 8 2)]
|
|
|
|
|
|
map columnTypeOf ["nope", ""] `shouldBe` [Nothing, Nothing]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
nullableTable <- postWith hs "/api/tables" "{\"name\":\"nullable_t\",\"columns\":[{\"name\":\"id\",\"type\":\"int\"},{\"name\":\"name\",\"type\":\"str\"},{\"name\":\"age\",\"type\":\"int\"}]}"
|
|
|
|
|
|
check (hasStatus nullableTable 200)
|
|
|
|
|
|
partial <- postWith hs "/api/tables/nullable_t/rows" "{\"values\":{\"id\":99}}"
|
|
|
|
|
|
check (hasStatus partial 200)
|
|
|
|
|
|
filled <- getWith hs "/api/tables/nullable_t/rows"
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
asInt (at "total" (jsonBody filled)) `shouldBe` 1
|
|
|
|
|
|
firstRow filled `shouldBe` [A.Number 99, A.Null, A.Null]
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "index: any column type can be indexed, the built-in id index cannot be dropped" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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\"}"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
check (hasStatus stringIndex 200)
|
|
|
|
|
|
droppedString <- request "DELETE" "/api/tables/users/indexes/name" hs ""
|
|
|
|
|
|
check (hasStatus droppedString 200)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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 "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 "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))
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 免密管理员:只有管理员能用空口令进来,普通账号一律 403
|
|
|
|
|
|
passwordlessSpec :: FilePath -> Spec
|
|
|
|
|
|
passwordlessSpec staticDir = describe "Auth: passwordless administrator" $
|
|
|
|
|
|
withPasswordlessWorld staticDir $ \_ -> do
|
|
|
|
|
|
it "the administrator signs in with an empty password" $ do
|
|
|
|
|
|
res <- loginAs "admin" ""
|
|
|
|
|
|
check (hasStatus res 204)
|
|
|
|
|
|
check (sessionCookieOf res `shouldSatisfy` maybe False (not . T.null))
|
|
|
|
|
|
it "a non-empty password is refused with 401" $
|
|
|
|
|
|
loginAs "admin" "s3cret" `shouldRespondWith` 401
|
|
|
|
|
|
it "an ordinary account is refused with 403 while the server is passwordless" $ do
|
|
|
|
|
|
res <- loginAs "someone" "whatever"
|
|
|
|
|
|
check (hasStatus res 403)
|
|
|
|
|
|
check (asText (at "error" (jsonBody res)) `shouldBe` "forbidden")
|
|
|
|
|
|
it "an unknown account with an empty password is refused too" $ do
|
|
|
|
|
|
res <- loginAs "ghost" ""
|
|
|
|
|
|
check (hasStatus res 403)
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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 "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 "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 "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 "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)
|
|
|
|
|
|
|
|
|
|
|
|
-- | 设置接口:读写配置与改口令
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
made <- postWith (systemHeaders (cookieHeaders admin)) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"guest\",\"password\":\"guest-password\"}}"
|
|
|
|
|
|
check (hasStatus made 200)
|
|
|
|
|
|
guest <- loginAs "guest" "guest-password"
|
|
|
|
|
|
check (hasStatus guest 204)
|
|
|
|
|
|
let guestHeaders = cookieHeaders guest
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
describe "Account table (system table, administrator only)" $
|
|
|
|
|
|
withWorldAt staticDir "accounts" $ \appEnv -> do
|
|
|
|
|
|
it "not logged in gives 401 on every account-table route" $ do
|
|
|
|
|
|
getWith [] "/api/tables" `shouldRespondWith` 401
|
|
|
|
|
|
getWith (systemHeaders []) "/api/tables/__chusql_users" `shouldRespondWith` 401
|
|
|
|
|
|
getWith (systemHeaders []) "/api/tables/__chusql_users/rows" `shouldRespondWith` 401
|
|
|
|
|
|
postWith (systemHeaders []) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"guest\",\"password\":\"guest-password\"}}" `shouldRespondWith` 401
|
|
|
|
|
|
patchWith (systemHeaders []) "/api/tables/__chusql_users/rows/guest" "{\"values\":{\"password\":\"guest-password\"}}" `shouldRespondWith` 401
|
|
|
|
|
|
deleteWith (systemHeaders []) "/api/tables/__chusql_users/rows/guest" `shouldRespondWith` 401
|
|
|
|
|
|
it "the retired account routes are gone" $ do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
res <- loginAs "admin" "s3cret"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
let hs = cookieHeaders res
|
|
|
|
|
|
switched <- postWith hs "/api/account/switch" "{\"user\":\"someone\",\"password\":\"someone-password\"}"
|
|
|
|
|
|
check (hasStatus switched 404)
|
|
|
|
|
|
listed <- getWith hs "/api/accounts"
|
|
|
|
|
|
check (hasStatus listed 404)
|
|
|
|
|
|
selfService <- postWith hs "/api/account/password" "{\"current\":\"s3cret\",\"next\":\"longenough\"}"
|
|
|
|
|
|
check (hasStatus selfService 404)
|
|
|
|
|
|
it "the administrator sees the account table with a secret password column" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
body <- getWith (systemHeaders (cookieHeaders admin)) "/api/tables"
|
|
|
|
|
|
check (hasStatus body 200)
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let entries = items (jsonBody body)
|
|
|
|
|
|
account = tableEntry "__chusql_users" entries
|
|
|
|
|
|
users = tableEntry "users" entries
|
|
|
|
|
|
at "table" account `shouldBe` A.String "__chusql_users"
|
|
|
|
|
|
at "kind" account `shouldBe` A.String "system"
|
|
|
|
|
|
map (asText . at "name") (items (at "columns" account)) `shouldBe` ["id", "user", "password", "registered_at", "last_login_at"]
|
|
|
|
|
|
map (asBool . at "secret") (items (at "columns" account)) `shouldBe` [False, False, True, False, False]
|
|
|
|
|
|
map (asBool . at "primaryKey") (items (at "columns" account)) `shouldBe` [True, False, False, False, False]
|
|
|
|
|
|
map (asText . at "type") (items (at "columns" account)) `shouldBe` ["int", "varchar(64)", "varchar(256)", "timestamp", "timestamp"]
|
|
|
|
|
|
map (asBool . at "indexed") (items (at "columns" account)) `shouldBe` [True, True, False, False, False]
|
|
|
|
|
|
at "kind" users `shouldBe` A.String "table"
|
|
|
|
|
|
detail <- getWith (systemHeaders (cookieHeaders admin)) "/api/tables/__chusql_users"
|
|
|
|
|
|
check (hasStatus detail 200)
|
|
|
|
|
|
check (at "kind" (jsonBody detail) `shouldBe` A.String "system")
|
|
|
|
|
|
it "insert, update and delete on the account table are CREATE, ALTER and DROP USER" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hs = cookieHeaders admin
|
|
|
|
|
|
made <- postWith (systemHeaders hs) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"Alice\",\"password\":\"alice-password\"}}"
|
|
|
|
|
|
check (hasStatus made 200)
|
|
|
|
|
|
page <- getWith (systemHeaders hs) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
check (hasStatus page 200)
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let rows = items (at "rows" (jsonBody page))
|
|
|
|
|
|
map asText (items (at "columns" (jsonBody page))) `shouldBe` ["id", "user", "password", "registered_at", "last_login_at"]
|
|
|
|
|
|
map (asText . (V.! 1) . asArray) rows `shouldBe` ["alice"]
|
|
|
|
|
|
length rows `shouldBe` 1
|
|
|
|
|
|
case firstRow page of
|
|
|
|
|
|
[_, _, _, A.String registered, lastLogin] -> do
|
|
|
|
|
|
registered `shouldSatisfy` (/= "")
|
|
|
|
|
|
lastLogin `shouldBe` A.Null
|
|
|
|
|
|
other -> expectationFailure ("unexpected account row: " ++ show other)
|
|
|
|
|
|
repeated <- postWith (systemHeaders hs) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"alice\",\"password\":\"alice-password\"}}"
|
|
|
|
|
|
check (hasStatus repeated 409)
|
|
|
|
|
|
alice <- loginAs "alice" "alice-password"
|
|
|
|
|
|
check (hasStatus alice 204)
|
|
|
|
|
|
adminAgain <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hsAgain = cookieHeaders adminAgain
|
|
|
|
|
|
stamped <- getWith (systemHeaders hsAgain) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
hasStatus stamped 200
|
|
|
|
|
|
case firstRow stamped of
|
|
|
|
|
|
[_, _, _, _, lastLogin] -> lastLogin `shouldSatisfy` (/= A.Null)
|
|
|
|
|
|
other -> expectationFailure ("unexpected account row: " ++ show other)
|
|
|
|
|
|
changed <- patchWith (systemHeaders hsAgain) "/api/tables/__chusql_users/rows/alice" "{\"values\":{\"password\":\"alice-other-pass\"}}"
|
|
|
|
|
|
check (hasStatus changed 200)
|
|
|
|
|
|
stalePassword <- loginAs "alice" "alice-password"
|
|
|
|
|
|
check (hasStatus stalePassword 401)
|
|
|
|
|
|
freshPassword <- loginAs "alice" "alice-other-pass"
|
|
|
|
|
|
check (hasStatus freshPassword 204)
|
|
|
|
|
|
adminLast <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hsLast = cookieHeaders adminLast
|
|
|
|
|
|
renamed <- patchWith (systemHeaders hsLast) "/api/tables/__chusql_users/rows/alice" "{\"values\":{\"user\":\"bob\"}}"
|
|
|
|
|
|
check (hasStatus renamed 400)
|
|
|
|
|
|
unknown <- deleteWith (systemHeaders hsLast) "/api/tables/__chusql_users/rows/nobody"
|
|
|
|
|
|
check (hasStatus unknown 404)
|
|
|
|
|
|
dropped <- deleteWith (systemHeaders hsLast) "/api/tables/__chusql_users/rows/alice"
|
|
|
|
|
|
check (hasStatus dropped 200)
|
|
|
|
|
|
gone <- loginAs "alice" "alice-other-pass"
|
|
|
|
|
|
check (hasStatus gone 401)
|
|
|
|
|
|
it "the account table can be sorted and filtered by user but not by password" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hs = cookieHeaders admin
|
|
|
|
|
|
_ <- postWith (systemHeaders hs) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"bob\",\"password\":\"bob-password\"}}"
|
|
|
|
|
|
_ <- postWith (systemHeaders hs) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"amy\",\"password\":\"amy-password\"}}"
|
|
|
|
|
|
sorted <- getWith (systemHeaders hs) "/api/tables/__chusql_users/rows?sort=user&dir=asc"
|
|
|
|
|
|
check (hasStatus sorted 200)
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let names = map (asText . (V.! 1) . asArray) (items (at "rows" (jsonBody sorted)))
|
|
|
|
|
|
names `shouldSatisfy` \ns -> ns == sort ns && all (`elem` ns) ["amy", "bob"]
|
|
|
|
|
|
filtered <- getWith (systemHeaders hs) "/api/tables/__chusql_users/rows?filter=%5B%7B%22column%22%3A%22user%22%2C%22value%22%3A%22bob%22%7D%5D"
|
|
|
|
|
|
check (hasStatus filtered 200)
|
|
|
|
|
|
check (map (asText . (V.! 1) . asArray) (items (at "rows" (jsonBody filtered))) `shouldBe` ["bob"])
|
|
|
|
|
|
byPassword <- getWith (systemHeaders hs) "/api/tables/__chusql_users/rows?sort=password"
|
|
|
|
|
|
check (hasStatus byPassword 400)
|
|
|
|
|
|
filterPassword <- getWith (systemHeaders hs) "/api/tables/__chusql_users/rows?filter=%5B%7B%22column%22%3A%22password%22%2C%22value%22%3A%22x%22%7D%5D"
|
|
|
|
|
|
check (hasStatus filterPassword 400)
|
|
|
|
|
|
it "the password column is stored as a hash, never in the settings file" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith (systemHeaders (cookieHeaders admin)) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"dave\",\"password\":\"dave-password\"}}"
|
|
|
|
|
|
stored <- liftIO (beAccounts (aeBackend appEnv) ReqAccountsList)
|
|
|
|
|
|
check $ case stored of
|
|
|
|
|
|
Left err -> expectationFailure err
|
|
|
|
|
|
Right accounts -> case filter ((== "dave") . accountUser) accounts of
|
|
|
|
|
|
[account] -> do
|
|
|
|
|
|
accountRevision account `shouldBe` 1
|
|
|
|
|
|
hashLooksValid (accountHash account) `shouldBe` True
|
|
|
|
|
|
verifyPassword (accountHash account) "dave-password" `shouldBe` True
|
|
|
|
|
|
_ -> expectationFailure "dave was not stored in the system table"
|
|
|
|
|
|
settings <- liftIO (readSettingsFile (aeSettingsFile appEnv))
|
|
|
|
|
|
check (Map.member "password" settings `shouldBe` False)
|
|
|
|
|
|
check (Map.member "password-hash" settings `shouldBe` False)
|
|
|
|
|
|
it "the account table has no indexes, cannot be dropped and cannot be renamed" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hs = cookieHeaders admin
|
|
|
|
|
|
index <- postWith (systemHeaders hs) "/api/tables/__chusql_users/indexes" "{\"column\":\"user\"}"
|
|
|
|
|
|
check (hasStatus index 400)
|
|
|
|
|
|
dropIndex <- deleteWith (systemHeaders hs) "/api/tables/__chusql_users/indexes/user"
|
|
|
|
|
|
check (hasStatus dropIndex 400)
|
|
|
|
|
|
dropColumn <- deleteWith (systemHeaders hs) "/api/tables/__chusql_users/columns/password"
|
|
|
|
|
|
check (hasStatus dropColumn 400)
|
|
|
|
|
|
dropped <- deleteWith (systemHeaders hs) "/api/tables/__chusql_users"
|
|
|
|
|
|
check (hasStatus dropped 400)
|
|
|
|
|
|
it "an ordinary account never sees or touches the account table" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
made <- postWith (systemHeaders (cookieHeaders admin)) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"guest\",\"password\":\"guest-password\"}}"
|
|
|
|
|
|
check (hasStatus made 200)
|
|
|
|
|
|
guest <- loginAs "guest" "guest-password"
|
|
|
|
|
|
check (hasStatus guest 204)
|
|
|
|
|
|
let guestHeaders = cookieHeaders guest
|
|
|
|
|
|
listed <- getWith guestHeaders "/api/tables"
|
|
|
|
|
|
check (hasStatus listed 200)
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody listed)) `shouldNotContain` ["__chusql_users"])
|
|
|
|
|
|
detail <- getWith (systemHeaders guestHeaders) "/api/tables/__chusql_users"
|
|
|
|
|
|
check (hasStatus detail 403)
|
|
|
|
|
|
rows <- getWith (systemHeaders guestHeaders) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
check (hasStatus rows 403)
|
|
|
|
|
|
created <- postWith (systemHeaders guestHeaders) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"bob\",\"password\":\"bob-password\"}}"
|
|
|
|
|
|
check (hasStatus created 403)
|
|
|
|
|
|
reset <- patchWith (systemHeaders guestHeaders) "/api/tables/__chusql_users/rows/guest" "{\"values\":{\"password\":\"guest-other-pass\"}}"
|
|
|
|
|
|
check (hasStatus reset 403)
|
|
|
|
|
|
dropped <- deleteWith (systemHeaders guestHeaders) "/api/tables/__chusql_users/rows/guest"
|
|
|
|
|
|
check (hasStatus dropped 403)
|
|
|
|
|
|
stillThere <- loginAs "guest" "guest-password"
|
|
|
|
|
|
check (hasStatus stillThere 204)
|
|
|
|
|
|
it "CREATE USER / ALTER USER / DROP USER go through the SQL console" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
created <- postWith (cookieHeaders admin) "/api/query" "{\"sql\":\"CREATE USER carol IDENTIFIED BY 'carol-password'\"}"
|
|
|
|
|
|
check (hasStatus created 200)
|
|
|
|
|
|
check (asInt (at "rowCount" (jsonBody created)) `shouldBe` 0)
|
|
|
|
|
|
changed <- postWith (cookieHeaders admin) "/api/query" "{\"sql\":\"ALTER USER carol IDENTIFIED BY 'carol-other-pass'\"}"
|
|
|
|
|
|
check (hasStatus changed 200)
|
|
|
|
|
|
dropped <- postWith (cookieHeaders admin) "/api/query" "{\"sql\":\"DROP USER carol\"}"
|
|
|
|
|
|
check (hasStatus dropped 200)
|
|
|
|
|
|
gone <- loginAs "carol" "carol-other-pass"
|
|
|
|
|
|
check (hasStatus gone 401)
|
|
|
|
|
|
it "an ordinary account cannot run account statements in the SQL console" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith (cookieHeaders admin) "/api/query" "{\"sql\":\"CREATE USER erin IDENTIFIED BY 'erin-password'\"}"
|
|
|
|
|
|
erin <- loginAs "erin" "erin-password"
|
|
|
|
|
|
check (hasStatus erin 204)
|
|
|
|
|
|
denials <- mapM (\sql -> postWith (cookieHeaders erin) "/api/query" (sqlBody sql))
|
|
|
|
|
|
[ "DROP USER erin"
|
|
|
|
|
|
, "ALTER USER erin IDENTIFIED BY 'erin-other-pass'"
|
|
|
|
|
|
, "CREATE USER frank IDENTIFIED BY 'frank-password'"
|
|
|
|
|
|
]
|
|
|
|
|
|
check (map (statusCode . WT.simpleStatus) denials `shouldBe` [403, 403, 403])
|
|
|
|
|
|
stillThere <- loginAs "erin" "erin-password"
|
|
|
|
|
|
check (hasStatus stillThere 204)
|
|
|
|
|
|
it "the administrator credentials are locked in the settings API" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
locked <- putWith (cookieHeaders admin) "/api/settings" "{\"values\":{\"password\":\"whatever\"}}"
|
|
|
|
|
|
check (hasStatus locked 403)
|
|
|
|
|
|
alsoLocked <- putWith (cookieHeaders admin) "/api/settings" "{\"values\":{\"user\":\"someone\"}}"
|
|
|
|
|
|
check (hasStatus alsoLocked 403)
|
|
|
|
|
|
body <- getWith (cookieHeaders admin) "/api/settings"
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let entries = items (at "items" (jsonBody body))
|
|
|
|
|
|
at "editable" (headEntry "user" entries) `shouldBe` A.Bool False
|
|
|
|
|
|
at "locked" (headEntry "user" entries) `shouldBe` A.Bool True
|
|
|
|
|
|
at "locked" (headEntry "rows-per-page" entries) `shouldBe` A.Bool False
|
|
|
|
|
|
|
|
|
|
|
|
describe "Databases (nothing is selected by default, system is administrator-only)" $
|
|
|
|
|
|
withWorldAt staticDir "databases" $ \_ -> do
|
|
|
|
|
|
it "lists exactly the databases that exist; the fixture database comes first" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
body <- getWith (cookieHeaders admin) "/api/databases"
|
|
|
|
|
|
check (hasStatus body 200)
|
|
|
|
|
|
check (map asText (items (jsonBody body)) `shouldBe` ["test", "system"])
|
|
|
|
|
|
it "the account table is listed in system, never in test" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hs = cookieHeaders admin
|
|
|
|
|
|
testList <- getWith hs "/api/tables"
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody testList)) `shouldNotContain` ["__chusql_users"])
|
|
|
|
|
|
systemList <- getWith (systemHeaders hs) "/api/tables"
|
|
|
|
|
|
check (hasStatus systemList 200)
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody systemList)) `shouldContain` ["__chusql_users"])
|
|
|
|
|
|
it "naming the account table from another database is a 400, not a silent lookup" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
outside <- getWith (cookieHeaders admin) "/api/tables/__chusql_users/rows"
|
|
|
|
|
|
check (hasStatus outside 400)
|
|
|
|
|
|
check (asText (at "message" (jsonBody outside)) `shouldBe` "the account table lives in the system database")
|
|
|
|
|
|
it "an ordinary account gets 403 on system and never sees it in the list" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
made <- postWith (systemHeaders (cookieHeaders admin)) "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"guest\",\"password\":\"guest-password\"}}"
|
|
|
|
|
|
check (hasStatus made 200)
|
|
|
|
|
|
guest <- loginAs "guest" "guest-password"
|
|
|
|
|
|
let guestHeaders = cookieHeaders guest
|
|
|
|
|
|
blocked <- getWith (systemHeaders guestHeaders) "/api/tables"
|
|
|
|
|
|
check (hasStatus blocked 403)
|
|
|
|
|
|
listed <- getWith guestHeaders "/api/databases"
|
|
|
|
|
|
check (hasStatus listed 200)
|
|
|
|
|
|
check (map asText (items (jsonBody listed)) `shouldBe` ["test"])
|
|
|
|
|
|
it "only the system database is reserved: test is an ordinary name" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let hs = cookieHeaders admin
|
|
|
|
|
|
reservedDrop <- deleteWith hs "/api/databases/system"
|
|
|
|
|
|
check (hasStatus reservedDrop 400)
|
|
|
|
|
|
check (asText (at "message" (jsonBody reservedDrop)) `shouldBe` "the system database cannot be dropped")
|
|
|
|
|
|
ordinaryDrop <- deleteWith hs "/api/databases/test"
|
|
|
|
|
|
check (hasStatus ordinaryDrop 400)
|
|
|
|
|
|
check (asText (at "message" (jsonBody ordinaryDrop)) `shouldSatisfy` T.isInfixOf "unavailable")
|
|
|
|
|
|
it "a request without a database header is a 400, not a silent default" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
let _hs = cookieHeaders admin
|
|
|
|
|
|
bareTables <- request methodGet "/api/tables" [] ""
|
|
|
|
|
|
check (hasStatus bareTables 400)
|
|
|
|
|
|
check (asText (at "message" (jsonBody bareTables)) `shouldBe` "no database selected")
|
|
|
|
|
|
bareQuery <- request methodPost "/api/query" [("Content-Type", "application/json")] "{\"sql\":\"SELECT * FROM users\"}"
|
|
|
|
|
|
check (hasStatus bareQuery 400)
|
|
|
|
|
|
check (asText (at "error" (jsonBody bareQuery)) `shouldBe` "no_database")
|
|
|
|
|
|
stillListed <- request methodGet "/api/databases" [] ""
|
|
|
|
|
|
check (hasStatus stillListed 200)
|
|
|
|
|
|
it "CREATE DATABASE in the SQL console still goes to the storage layer" $ do
|
|
|
|
|
|
admin <- loginAs "admin" "s3cret"
|
|
|
|
|
|
created <- postWith (cookieHeaders admin) "/api/query" "{\"sql\":\"CREATE DATABASE sales\"}"
|
|
|
|
|
|
check (hasStatus created 400)
|
|
|
|
|
|
check (asText (at "message" (jsonBody created)) `shouldSatisfy` T.isInfixOf "unavailable")
|
|
|
|
|
|
|
|
|
|
|
|
-- | 角色与权限:REST 端点、SQL 语句、越权口径、内部表不可见
|
|
|
|
|
|
privilegesSpec :: FilePath -> Spec
|
|
|
|
|
|
privilegesSpec staticDir = withWorldAt staticDir "privileges" $ \_ -> do
|
|
|
|
|
|
-- 注意:wai-extra 的 Session 自带 Cookie 罐,罐里的令牌会盖过显式 Cookie 头,
|
|
|
|
|
|
-- 所以每个身份都得在动手前重新登录一次(和既有用例组同一套写法)。
|
|
|
|
|
|
let signIn user password = do
|
|
|
|
|
|
res <- loginAs user password
|
|
|
|
|
|
expectStatusWith ("sign in " <> T.unpack user) res 204
|
|
|
|
|
|
it "the administrator creates a role, grants SELECT and adds a member" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
made <- postWith [] "/api/roles" "{\"name\":\"analyst\"}"
|
|
|
|
|
|
check (hasStatus made 200)
|
|
|
|
|
|
granted <- postWith [] "/api/roles/analyst/grants" "{\"object\":\"users\",\"privileges\":[\"select\"]}"
|
|
|
|
|
|
check (hasStatus granted 200)
|
|
|
|
|
|
_ <- postWith [] "/api/query" (sqlBody "CREATE USER dana IDENTIFIED BY 'dana-password'")
|
|
|
|
|
|
joined <- postWith [] "/api/roles/analyst/members" "{\"user\":\"dana\"}"
|
|
|
|
|
|
check (hasStatus joined 200)
|
|
|
|
|
|
listed <- getWith [] "/api/roles"
|
|
|
|
|
|
check (hasStatus listed 200)
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let analyst = roleEntry "analyst" (items (jsonBody listed))
|
|
|
|
|
|
at "name" analyst `shouldBe` A.String "analyst"
|
|
|
|
|
|
map (asText . at "privilege") (items (at "grants" analyst)) `shouldBe` ["select"]
|
|
|
|
|
|
map (asText . at "object") (items (at "grants" analyst)) `shouldBe` ["test.users"]
|
|
|
|
|
|
map asText (items (at "members" analyst)) `shouldBe` ["dana"]
|
|
|
|
|
|
signIn "dana" "dana-password"
|
|
|
|
|
|
visible <- getWith [] "/api/tables"
|
|
|
|
|
|
check (hasStatus visible 200)
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody visible)) `shouldBe` ["users"])
|
|
|
|
|
|
allowed <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
check (hasStatus allowed 200)
|
|
|
|
|
|
check (asInt (at "rowCount" (jsonBody allowed)) `shouldBe` 5)
|
|
|
|
|
|
denied <- postWith [] "/api/query" (sqlBody "SELECT * FROM orders")
|
|
|
|
|
|
check (hasStatus denied 403)
|
|
|
|
|
|
check (asText (at "error" (jsonBody denied)) `shouldBe` "forbidden")
|
|
|
|
|
|
joins <- postWith [] "/api/query" (sqlBody "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id")
|
|
|
|
|
|
check (hasStatus joins 403)
|
|
|
|
|
|
nested <- postWith [] "/api/query" (sqlBody "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)")
|
|
|
|
|
|
check (hasStatus nested 403)
|
|
|
|
|
|
detail <- getWith [] "/api/tables/orders/rows"
|
|
|
|
|
|
check (hasStatus detail 403)
|
|
|
|
|
|
rows <- getWith [] "/api/tables/users/rows"
|
|
|
|
|
|
check (hasStatus rows 200)
|
|
|
|
|
|
inserted <- postWith [] "/api/tables/users/rows" "{\"values\":{\"id\":9,\"name\":\"nine\",\"age\":30}}"
|
|
|
|
|
|
check (hasStatus inserted 403)
|
|
|
|
|
|
it "grants of insert/update/delete unlock the one-click writes, DDL stays administrator-only" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith [] "/api/roles" "{\"name\":\"editor\"}"
|
|
|
|
|
|
_ <- postWith [] "/api/roles/editor/grants" "{\"object\":\"users\",\"privileges\":[\"insert\",\"update\",\"delete\"]}"
|
|
|
|
|
|
_ <- postWith [] "/api/query" (sqlBody "CREATE USER evan IDENTIFIED BY 'evan-password'")
|
|
|
|
|
|
_ <- postWith [] "/api/roles/editor/members" "{\"users\":[\"evan\"]}"
|
|
|
|
|
|
signIn "evan" "evan-password"
|
|
|
|
|
|
inserted <- postWith [] "/api/tables/users/rows" "{\"values\":{\"id\":99,\"name\":\"nine\",\"age\":30}}"
|
|
|
|
|
|
check (hasStatus inserted 200)
|
|
|
|
|
|
changed <- patchWith [] "/api/tables/users/rows/99" "{\"values\":{\"age\":31}}"
|
|
|
|
|
|
check (hasStatus changed 200)
|
|
|
|
|
|
removed <- deleteWith [] "/api/tables/users/rows/99"
|
|
|
|
|
|
check (hasStatus removed 200)
|
|
|
|
|
|
visible <- getWith [] "/api/tables"
|
|
|
|
|
|
check (hasStatus visible 200)
|
|
|
|
|
|
check (length (items (jsonBody visible)) `shouldBe` 0)
|
|
|
|
|
|
readDenied <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
check (hasStatus readDenied 403)
|
|
|
|
|
|
dropped <- deleteWith [] "/api/tables/users"
|
|
|
|
|
|
check (hasStatus dropped 403)
|
|
|
|
|
|
ddl <- postWith [] "/api/query" (sqlBody "CREATE TABLE t9 (id INT)")
|
|
|
|
|
|
check (hasStatus ddl 403)
|
|
|
|
|
|
roles <- getWith [] "/api/roles"
|
|
|
|
|
|
check (hasStatus roles 403)
|
|
|
|
|
|
roleSql <- postWith [] "/api/query" (sqlBody "CREATE ROLE sneak")
|
|
|
|
|
|
check (hasStatus roleSql 403)
|
|
|
|
|
|
it "a wildcard grant covers every table" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith [] "/api/roles" "{\"name\":\"supervisor\"}"
|
|
|
|
|
|
_ <- postWith [] "/api/roles/supervisor/grants" "{\"object\":\"*\",\"privileges\":[\"select\"]}"
|
|
|
|
|
|
_ <- postWith [] "/api/query" (sqlBody "CREATE USER ivy IDENTIFIED BY 'ivy-password'")
|
|
|
|
|
|
_ <- postWith [] "/api/roles/supervisor/members" "{\"user\":\"ivy\"}"
|
|
|
|
|
|
signIn "ivy" "ivy-password"
|
|
|
|
|
|
visible <- getWith [] "/api/tables"
|
|
|
|
|
|
check (hasStatus visible 200)
|
|
|
|
|
|
check (sort (map (asText . at "table") (items (jsonBody visible))) `shouldBe` ["orders", "users"])
|
|
|
|
|
|
both <- postWith [] "/api/query" (sqlBody "SELECT * FROM orders")
|
|
|
|
|
|
check (hasStatus both 200)
|
|
|
|
|
|
innerJoin <- postWith [] "/api/query" (sqlBody "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id")
|
|
|
|
|
|
check (hasStatus innerJoin 200)
|
|
|
|
|
|
it "revoking membership and privileges closes the door again" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith [] "/api/roles" "{\"name\":\"temp\"}"
|
|
|
|
|
|
_ <- postWith [] "/api/roles/temp/grants" "{\"object\":\"users\",\"privileges\":[\"select\"]}"
|
|
|
|
|
|
_ <- postWith [] "/api/query" (sqlBody "CREATE USER gina IDENTIFIED BY 'gina-password'")
|
|
|
|
|
|
_ <- postWith [] "/api/roles/temp/members" "{\"user\":\"gina\"}"
|
|
|
|
|
|
signIn "gina" "gina-password"
|
|
|
|
|
|
grantedSelect <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
expectStatusWith "gina selects after the grant" grantedSelect 200
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
evicted <- deleteWith [] "/api/roles/temp/members/gina"
|
|
|
|
|
|
expectStatusWith "evict gina" evicted 200
|
|
|
|
|
|
signIn "gina" "gina-password"
|
|
|
|
|
|
afterEviction <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
expectStatusWith "gina select after eviction" afterEviction 403
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
readded <- postWith [] "/api/roles/temp/members" "{\"user\":\"gina\"}"
|
|
|
|
|
|
expectStatusWith "re-add gina" readded 200
|
|
|
|
|
|
signIn "gina" "gina-password"
|
|
|
|
|
|
back <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
expectStatusWith "gina selects again" back 200
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
revoked <- deleteJsonWith [] "/api/roles/temp/grants" "{\"object\":\"users\",\"privileges\":[\"select\"]}"
|
|
|
|
|
|
expectStatusWith "revoke the grant" revoked 200
|
|
|
|
|
|
signIn "gina" "gina-password"
|
|
|
|
|
|
empty <- getWith [] "/api/tables"
|
|
|
|
|
|
expectStatusWith "gina table list after revoke" empty 200
|
|
|
|
|
|
check (items (jsonBody empty) `shouldBe` [])
|
|
|
|
|
|
afterRevoke <- postWith [] "/api/query" (sqlBody "SELECT * FROM users")
|
|
|
|
|
|
check (hasStatus afterRevoke 403)
|
|
|
|
|
|
it "role statements run in the SQL console and conflicts and unknown roles are reported" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
created <- postWith [] "/api/query" (sqlBody "CREATE ROLE auditor")
|
|
|
|
|
|
check (hasStatus created 200)
|
|
|
|
|
|
granted <- postWith [] "/api/query" (sqlBody "GRANT SELECT ON * TO auditor")
|
|
|
|
|
|
check (hasStatus granted 200)
|
|
|
|
|
|
listed <- getWith [] "/api/roles"
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let auditor = roleEntry "auditor" (items (jsonBody listed))
|
|
|
|
|
|
map (asText . at "privilege") (items (at "grants" auditor)) `shouldBe` ["select"]
|
|
|
|
|
|
map (asText . at "object") (items (at "grants" auditor)) `shouldBe` ["*"]
|
|
|
|
|
|
duplicate <- postWith [] "/api/roles" "{\"name\":\"auditor\"}"
|
|
|
|
|
|
check (hasStatus duplicate 409)
|
|
|
|
|
|
unknownGrant <- postWith [] "/api/roles/nobody/grants" "{\"object\":\"users\",\"privileges\":[\"select\"]}"
|
|
|
|
|
|
check (hasStatus unknownGrant 404)
|
|
|
|
|
|
unknownMember <- postWith [] "/api/roles/nobody/members" "{\"user\":\"dana\"}"
|
|
|
|
|
|
check (hasStatus unknownMember 404)
|
|
|
|
|
|
badPrivilege <- postWith [] "/api/query" (sqlBody "GRANT EXECUTE ON users TO auditor")
|
|
|
|
|
|
check (hasStatus badPrivilege 400)
|
|
|
|
|
|
revoked <- postWith [] "/api/query" (sqlBody "REVOKE SELECT ON * FROM auditor")
|
|
|
|
|
|
check (hasStatus revoked 200)
|
|
|
|
|
|
removed <- postWith [] "/api/query" (sqlBody "DROP ROLE auditor")
|
|
|
|
|
|
check (hasStatus removed 200)
|
|
|
|
|
|
gone <- getWith [] "/api/roles"
|
|
|
|
|
|
check (map (asText . at "name") (items (jsonBody gone)) `shouldNotContain` ["auditor"])
|
|
|
|
|
|
it "ALL expands into the four concrete privileges" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith [] "/api/roles" "{\"name\":\"owner\"}"
|
|
|
|
|
|
_ <- postWith [] "/api/roles/owner/grants" "{\"object\":\"orders\",\"privileges\":[\"all\"]}"
|
|
|
|
|
|
listed <- getWith [] "/api/roles"
|
|
|
|
|
|
check $ do
|
|
|
|
|
|
let owner = roleEntry "owner" (items (jsonBody listed))
|
|
|
|
|
|
sort (map (asText . at "privilege") (items (at "grants" owner))) `shouldBe` ["delete", "insert", "select", "update"]
|
|
|
|
|
|
map (asText . at "object") (items (at "grants" owner)) `shouldBe` replicate 4 "test.orders"
|
|
|
|
|
|
it "the tables that hold roles and grants are invisible" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
testList <- getWith [] "/api/tables"
|
|
|
|
|
|
check (hasStatus testList 200)
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody testList)) `shouldNotContain` ["sys_roles", "sys_grants", "sys_members"])
|
|
|
|
|
|
systemList <- getWith (systemHeaders []) "/api/tables"
|
|
|
|
|
|
check (hasStatus systemList 200)
|
|
|
|
|
|
check (map (asText . at "table") (items (jsonBody systemList)) `shouldNotContain` ["sys_roles", "sys_grants", "sys_members"])
|
|
|
|
|
|
hidden <- getWith [] "/api/tables/sys_roles"
|
|
|
|
|
|
check (hasStatus hidden 404)
|
|
|
|
|
|
peek <- postWith [] "/api/query" (sqlBody "SELECT * FROM sys_roles")
|
|
|
|
|
|
check (statusCode (WT.simpleStatus peek) `shouldSatisfy` (`elem` [400, 403, 404]))
|
|
|
|
|
|
it "an account with no role has no access at all" $ do
|
|
|
|
|
|
signIn "admin" "s3cret"
|
|
|
|
|
|
_ <- postWith [] "/api/query" (sqlBody "CREATE USER frank IDENTIFIED BY 'frank-password'")
|
|
|
|
|
|
signIn "frank" "frank-password"
|
|
|
|
|
|
visible <- getWith [] "/api/tables"
|
|
|
|
|
|
check (hasStatus visible 200)
|
|
|
|
|
|
check (items (jsonBody visible) `shouldBe` [])
|
|
|
|
|
|
forM_
|
|
|
|
|
|
[ "SELECT * FROM users"
|
|
|
|
|
|
, "INSERT INTO users (id, name, age) VALUES (7, 'seven', 7)"
|
|
|
|
|
|
, "UPDATE users SET age = 8 WHERE id = 7"
|
|
|
|
|
|
, "DELETE FROM users WHERE id = 7"
|
|
|
|
|
|
]
|
|
|
|
|
|
$ \sql -> do
|
|
|
|
|
|
res <- postWith [] "/api/query" (sqlBody sql)
|
|
|
|
|
|
check (hasStatus res 403)
|
|
|
|
|
|
detail <- getWith [] "/api/tables/users/rows"
|
|
|
|
|
|
check (hasStatus detail 403)
|
|
|
|
|
|
databases <- getWith [] "/api/databases"
|
|
|
|
|
|
check (map asText (items (jsonBody databases)) `shouldBe` ["test"])
|
|
|
|
|
|
|
|
|
|
|
|
-- | 在角色清单里按名字找一项
|
|
|
|
|
|
roleEntry :: Text -> [A.Value] -> A.Value
|
|
|
|
|
|
roleEntry name entries = case [e | e <- entries, asText (at "name" e) == name] of
|
|
|
|
|
|
(e : _) -> e
|
|
|
|
|
|
[] -> A.Null
|
|
|
|
|
|
|
|
|
|
|
|
-- | 带 JSON 体的 DELETE(撤销授权要用)
|
|
|
|
|
|
deleteJsonWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse
|
|
|
|
|
|
deleteJsonWith headers path payload =
|
|
|
|
|
|
request "DELETE" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload
|
|
|
|
|
|
|
|
|
|
|
|
-- | 一条 SQL 请求体
|
|
|
|
|
|
sqlBody :: Text -> BL.ByteString
|
|
|
|
|
|
sqlBody sql = BL.fromStrict (TE.encodeUtf8 (T.concat ["{\"sql\":\"", sql, "\"}"]))
|
|
|
|
|
|
|
|
|
|
|
|
-- | 在表清单里按表名找一项(找不到给 Null)
|
|
|
|
|
|
tableEntry :: Text -> [A.Value] -> A.Value
|
|
|
|
|
|
tableEntry name entries = case [e | e <- entries, asText (at "table" e) == name] of
|
|
|
|
|
|
(e : _) -> e
|
|
|
|
|
|
[] -> A.Null
|
|
|
|
|
|
|
|
|
|
|
|
-- | JSON 当布尔看
|
|
|
|
|
|
asBool :: A.Value -> Bool
|
|
|
|
|
|
asBool (A.Bool b) = b
|
|
|
|
|
|
asBool _ = False
|
|
|
|
|
|
|
|
|
|
|
|
-- | JSON 当数组看(不是数组给空)
|
|
|
|
|
|
asArray :: A.Value -> V.Vector A.Value
|
|
|
|
|
|
asArray (A.Array xs) = xs
|
|
|
|
|
|
asArray _ = V.empty
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 一份完整的合法 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
|
|
|
|
|
|
|
|
|
|
-- | 上面那份设置解出来的值
|
2026-10-01 20:48:38 +08:00
|
|
|
|
fullUISettings :: A.Value
|
|
|
|
|
|
fullUISettings = fromMaybe A.Null (A.decode uiSettingsBody)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 在合法设置上换掉一个字段(试坏值用)
|
|
|
|
|
|
withUiField :: Text -> A.Value -> A.Value
|
2026-10-01 20:48:38 +08:00
|
|
|
|
withUiField key value = case fullUISettings of
|
2026-09-25 17:41:51 +08:00
|
|
|
|
A.Object o -> A.Object (KM.insert (K.fromText key) value o)
|
|
|
|
|
|
other -> other
|
|
|
|
|
|
|
|
|
|
|
|
uiSettingsSpec :: FilePath -> Spec
|
|
|
|
|
|
uiSettingsSpec staticDir = do
|
|
|
|
|
|
describe "IDE settings validation" $ do
|
|
|
|
|
|
it "accepts a complete valid object" $
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings fullUISettings `shouldSatisfy` isRightE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "rejects a string where an integer is expected" $
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings (withUiField "uiFontSize" (A.String "13")) `shouldSatisfy` isLeftE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "rejects an integer outside its range" $ do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings (withUiField "uiFontSize" (A.Number 10)) `shouldSatisfy` isLeftE
|
|
|
|
|
|
validateUISettings (withUiField "uiFontSize" (A.Number 17)) `shouldSatisfy` isLeftE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "rejects a blank font name" $
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings (withUiField "gridFonts" (A.Array (V.fromList [A.String " "]))) `shouldSatisfy` isLeftE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "rejects a number where a boolean is expected" $
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings (withUiField "minimap" (A.Number 1)) `shouldSatisfy` isLeftE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "names the offending key in the error" $
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case validateUISettings (withUiField "pageSize" (A.Number 5)) of
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Left err -> err `shouldSatisfy` isInfixOf "pageSize"
|
|
|
|
|
|
Right _ -> expectationFailure "expected a range error"
|
|
|
|
|
|
|
|
|
|
|
|
describe "IDE settings file" $ do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "candidates look for the file in scripts/ first" $
|
2026-09-25 17:41:51 +08:00
|
|
|
|
uiSettingsFileCandidates
|
2026-10-01 20:48:38 +08:00
|
|
|
|
`shouldBe` [ "scripts" </> "chusql.ui.settings.json"
|
|
|
|
|
|
, ".." </> "scripts" </> "chusql.ui.settings.json"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, "chusql.ui.settings.json"
|
|
|
|
|
|
]
|
|
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
written <- writeUISettings path stored
|
2026-09-25 17:41:51 +08:00
|
|
|
|
written `shouldBe` Right ()
|
2026-10-01 20:48:38 +08:00
|
|
|
|
readUISettings path `shouldReturn` A.Object (KM.fromList [("minimap", A.Bool True), ("uiFontSize", A.Number 13)])
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
check (jsonBody body `shouldBe` fullUISettings)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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 "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 "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 "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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "isolates database contexts across HTTP clients and supports qualified SQL" $ do
|
|
|
|
|
|
serverBin <- locateServerOrSkip
|
|
|
|
|
|
(pipe, _dataDir, cfg) <- makeServerConfig "iso"
|
|
|
|
|
|
started <- startStorageProcess serverBin cfg
|
|
|
|
|
|
running <- either fail pure started
|
|
|
|
|
|
bracket (pure running) stopStorageProcess $ \_sp -> do
|
|
|
|
|
|
backend <- ipcBackend
|
|
|
|
|
|
sessions <- newSessionStore getCurrentTime defaultSessionPolicy
|
|
|
|
|
|
limiter <- newRateLimiter getCurrentTime 50 300
|
|
|
|
|
|
env <- newAppEnv backend sessions testCredential limiter staticDir
|
|
|
|
|
|
app <- webApp env
|
|
|
|
|
|
Warp.testWithApplication (pure app) $ \port -> do
|
|
|
|
|
|
mgr <- newManager defaultManagerSettings
|
|
|
|
|
|
signedInResponse <- e2ePost mgr port "/api/login" (loginBody "admin" "s3cret") []
|
|
|
|
|
|
let auth = [("Cookie", fromMaybe "" (e2eCookie signedInResponse))]
|
|
|
|
|
|
alpha = ("X-ChuSQL-Database", "alpha") : auth
|
|
|
|
|
|
beta = ("X-ChuSQL-Database", "beta") : auth
|
|
|
|
|
|
query headers sql = e2ePost mgr port "/api/query" (sqlBody sql) headers
|
|
|
|
|
|
mapM_ (\name -> do
|
|
|
|
|
|
created <- query auth ("CREATE DATABASE " <> name)
|
|
|
|
|
|
statusCode (responseStatus created) `shouldBe` 200) ["alpha", "beta"]
|
|
|
|
|
|
mapM_ (\headers -> do
|
|
|
|
|
|
created <- query headers "CREATE TABLE items (id int)"
|
|
|
|
|
|
statusCode (responseStatus created) `shouldBe` 200) [alpha, beta]
|
|
|
|
|
|
inserted <- query alpha "INSERT INTO items (id) VALUES (1)"
|
|
|
|
|
|
statusCode (responseStatus inserted) `shouldBe` 200
|
|
|
|
|
|
selected <- query beta "SELECT * FROM items"
|
|
|
|
|
|
asInt (at "rowCount" (fromMaybe A.Null (decode (responseBody selected)))) `shouldBe` 0
|
|
|
|
|
|
qualified <- query beta "SELECT * FROM alpha.items"
|
|
|
|
|
|
asInt (at "rowCount" (fromMaybe A.Null (decode (responseBody qualified)))) `shouldBe` 1
|
|
|
|
|
|
betaInsert <- query beta "INSERT INTO items (id) VALUES (1)"
|
|
|
|
|
|
statusCode (responseStatus betaInsert) `shouldBe` 200
|
|
|
|
|
|
joined <- query auth "SELECT a.id FROM alpha.items a JOIN beta.items b ON a.id = b.id"
|
|
|
|
|
|
asInt (at "rowCount" (fromMaybe A.Null (decode (responseBody joined)))) `shouldBe` 1
|
|
|
|
|
|
inherited <- getEnvironment
|
|
|
|
|
|
-- 构建好的 chusql-cli 也要能一条命令跑通(没构建就跳过)
|
|
|
|
|
|
locateCliExe >>= \found -> case found of
|
|
|
|
|
|
Nothing -> pendingWith "csql is not built (run stack build chusql-cli:exe:csql)"
|
|
|
|
|
|
Just cliExe -> do
|
|
|
|
|
|
cliConfig <- makeCliHome port pipe "admin" "s3cret"
|
|
|
|
|
|
(cliExit, cliOut, cliErr) <-
|
|
|
|
|
|
readCreateProcessWithExitCode
|
|
|
|
|
|
(proc cliExe ["--config", cliConfig, "--database", "alpha", "--format", "json", "-e", "SELECT * FROM items"])
|
|
|
|
|
|
{ Process.env = Just inherited
|
|
|
|
|
|
}
|
|
|
|
|
|
""
|
|
|
|
|
|
(cliExit, cliErr) `shouldBe` (ExitSuccess, "")
|
|
|
|
|
|
asInt (at "rowCount" (fromMaybe A.Null (decode (BL.fromStrict (BSC.pack cliOut))))) `shouldBe` 1
|
|
|
|
|
|
switched <- query auth "USE alpha"
|
|
|
|
|
|
at "database" (fromMaybe A.Null (decode (responseBody switched))) `shouldBe` A.String "alpha"
|
|
|
|
|
|
-- 限定名不需要先选库;裸表名没有当前库就是 400
|
|
|
|
|
|
qualifiedWithoutDatabase <- query auth "SELECT * FROM alpha.items"
|
|
|
|
|
|
asInt (at "rowCount" (fromMaybe A.Null (decode (responseBody qualifiedWithoutDatabase)))) `shouldBe` 1
|
|
|
|
|
|
bareWithoutDatabase <- query auth "SELECT * FROM items"
|
|
|
|
|
|
statusCode (responseStatus bareWithoutDatabase) `shouldBe` 400
|
|
|
|
|
|
at "error" (fromMaybe A.Null (decode (responseBody bareWithoutDatabase))) `shouldBe` A.String "no_database"
|
|
|
|
|
|
listed <- e2eGet mgr port "/api/databases" auth
|
|
|
|
|
|
items (fromMaybe A.Null (decode (responseBody listed))) `shouldBe` map A.String ["alpha", "beta", "system"]
|
|
|
|
|
|
createdDb <- e2ePost mgr port "/api/databases" "{\"name\":\"gamma\"}" auth
|
|
|
|
|
|
statusCode (responseStatus createdDb) `shouldBe` 200
|
|
|
|
|
|
duplicateDb <- e2ePost mgr port "/api/databases" "{\"name\":\"gamma\"}" auth
|
|
|
|
|
|
statusCode (responseStatus duplicateDb) `shouldBe` 400
|
|
|
|
|
|
droppedDb <- e2eSend mgr port "DELETE" "/api/databases/gamma" "" auth
|
|
|
|
|
|
statusCode (responseStatus droppedDb) `shouldBe` 200
|
|
|
|
|
|
keptSystem <- e2eSend mgr port "DELETE" "/api/databases/system" "" auth
|
|
|
|
|
|
statusCode (responseStatus keptSystem) `shouldBe` 400
|
|
|
|
|
|
-- test 不再是保留名:能建也能删
|
|
|
|
|
|
madeTest <- e2ePost mgr port "/api/databases" "{\"name\":\"test\"}" auth
|
|
|
|
|
|
statusCode (responseStatus madeTest) `shouldBe` 200
|
|
|
|
|
|
droppedTest <- e2eSend mgr port "DELETE" "/api/databases/test" "" auth
|
|
|
|
|
|
statusCode (responseStatus droppedTest) `shouldBe` 200
|
|
|
|
|
|
bad <- query (("X-ChuSQL-Database", "../escape") : auth) "SELECT 1"
|
|
|
|
|
|
statusCode (responseStatus bad) `shouldBe` 400
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
it "login -> create table -> insert -> query -> logout all the way through" $ do
|
|
|
|
|
|
serverBin <- locateServerOrSkip
|
2026-10-01 20:48:38 +08:00
|
|
|
|
(_pipe, dataDir, cfg) <- makeServerConfig "flow"
|
|
|
|
|
|
started <- startStorageProcess serverBin cfg
|
2026-09-25 17:41:51 +08:00
|
|
|
|
running <- case started of
|
|
|
|
|
|
Left err -> do
|
|
|
|
|
|
expectationFailure err
|
|
|
|
|
|
fail "the storage process did not start"
|
|
|
|
|
|
Right ok -> pure ok
|
2026-10-01 20:48:38 +08:00
|
|
|
|
bracket (pure running) stopStorageProcess $ \_sp -> do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
let bare = [("Cookie", fromMaybe "" cookie)]
|
|
|
|
|
|
-- 服务不再自带默认库:先建一个自己的库,再把它当请求上下文
|
|
|
|
|
|
madeDb <- e2ePost mgr boundPort "/api/databases" "{\"name\":\"main\"}" bare
|
|
|
|
|
|
statusCode (responseStatus madeDb) `shouldBe` 200
|
|
|
|
|
|
let auth = ("X-ChuSQL-Database", "main") : bare
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
exists <- doesDirectoryExist dataDir
|
2026-09-25 17:41:51 +08:00
|
|
|
|
exists `shouldBe` True
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
it "ordinary accounts survive restart, the administrator resets passwords and ordinary users cannot administer accounts" $ do
|
|
|
|
|
|
serverBin <- locateServerOrSkip
|
|
|
|
|
|
(_pipe, _dataDir, cfg) <- makeServerConfig "restart"
|
|
|
|
|
|
Right running <- startStorageProcess serverBin cfg
|
|
|
|
|
|
bracket (pure running) stopStorageProcess $ \sp -> do
|
|
|
|
|
|
backend <- ipcBackend
|
|
|
|
|
|
sessions <- newSessionStore getCurrentTime defaultSessionPolicy
|
|
|
|
|
|
limiter <- newRateLimiter getCurrentTime 50 300
|
|
|
|
|
|
env <- newAppEnv backend sessions testCredential limiter staticDir
|
|
|
|
|
|
app <- webApp env
|
|
|
|
|
|
Warp.testWithApplication (pure app) $ \port -> do
|
|
|
|
|
|
mgr <- newManager defaultManagerSettings
|
|
|
|
|
|
admin <- e2ePost mgr port "/api/login" (loginBody "admin" "s3cret") []
|
|
|
|
|
|
let auth = [("Cookie", fromMaybe "" (e2eCookie admin))]
|
|
|
|
|
|
authSystem = ("X-ChuSQL-Database", "system") : auth
|
|
|
|
|
|
created <- e2ePost mgr port "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"alice\",\"password\":\"alice-password\"}}" authSystem
|
|
|
|
|
|
statusCode (responseStatus created) `shouldBe` 200
|
|
|
|
|
|
duplicate <- e2ePost mgr port "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"ALICE\",\"password\":\"alice-password\"}}" authSystem
|
|
|
|
|
|
statusCode (responseStatus duplicate) `shouldBe` 409
|
|
|
|
|
|
goneSwitch <- e2ePost mgr port "/api/account/switch" (loginBody "alice" "alice-password") auth
|
|
|
|
|
|
statusCode (responseStatus goneSwitch) `shouldBe` 404
|
|
|
|
|
|
stillAdmin <- e2eGet mgr port "/api/session" auth
|
|
|
|
|
|
statusCode (responseStatus stillAdmin) `shouldBe` 200
|
|
|
|
|
|
alice <- e2ePost mgr port "/api/login" (loginBody "alice" "alice-password") []
|
|
|
|
|
|
statusCode (responseStatus alice) `shouldBe` 204
|
|
|
|
|
|
let aliceAuth = [("Cookie", fromMaybe "" (e2eCookie alice))]
|
|
|
|
|
|
aliceSystem = ("X-ChuSQL-Database", "system") : aliceAuth
|
|
|
|
|
|
misplaced <- e2ePost mgr port "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"other\",\"password\":\"other-password\"}}" aliceAuth
|
|
|
|
|
|
statusCode (responseStatus misplaced) `shouldBe` 400
|
|
|
|
|
|
forbidden <- e2ePost mgr port "/api/tables/__chusql_users/rows" "{\"values\":{\"user\":\"other\",\"password\":\"other-password\"}}" aliceSystem
|
|
|
|
|
|
statusCode (responseStatus forbidden) `shouldBe` 403
|
|
|
|
|
|
listed <- e2eGet mgr port "/api/tables/__chusql_users/rows" aliceSystem
|
|
|
|
|
|
statusCode (responseStatus listed) `shouldBe` 403
|
|
|
|
|
|
blockedSystem <- e2eGet mgr port "/api/tables" aliceSystem
|
|
|
|
|
|
statusCode (responseStatus blockedSystem) `shouldBe` 403
|
|
|
|
|
|
aliceDbs <- e2eGet mgr port "/api/databases" aliceAuth
|
|
|
|
|
|
items (fromMaybe A.Null (decode (responseBody aliceDbs))) `shouldSatisfy` not . elem (A.String "system")
|
|
|
|
|
|
tableList <- e2eGet mgr port "/api/tables" aliceAuth
|
|
|
|
|
|
statusCode (responseStatus tableList) `shouldBe` 400
|
|
|
|
|
|
at "error" (fromMaybe A.Null (decode (responseBody tableList))) `shouldBe` A.String "no_database"
|
|
|
|
|
|
_ <- e2ePost mgr port "/api/databases" "{\"name\":\"shop\"}" auth
|
|
|
|
|
|
shopTables <- e2eGet mgr port "/api/tables" (("X-ChuSQL-Database", "shop") : aliceAuth)
|
|
|
|
|
|
statusCode (responseStatus shopTables) `shouldBe` 200
|
|
|
|
|
|
BL.toStrict (responseBody shopTables) `shouldSatisfy` (not . BS.isInfixOf "__chusql_users")
|
|
|
|
|
|
settings <- e2eSend mgr port "PUT" "/api/settings" "{\"values\":{\"password-min-length\":\"8\"}}" aliceAuth
|
|
|
|
|
|
statusCode (responseStatus settings) `shouldBe` 403
|
|
|
|
|
|
hidden <- e2ePost mgr port "/api/query" "{\"sql\":\"SELECT * FROM __chusql_users\"}" aliceAuth
|
|
|
|
|
|
statusCode (responseStatus hidden) `shouldSatisfy` (>= 400)
|
|
|
|
|
|
BL.toStrict (responseBody hidden) `shouldSatisfy` (not . BS.isInfixOf "pbkdf2")
|
|
|
|
|
|
selfService <- e2ePost mgr port "/api/account/password" "{\"current\":\"alice-password\",\"next\":\"alice-new-password\"}" aliceAuth
|
|
|
|
|
|
statusCode (responseStatus selfService) `shouldBe` 404
|
|
|
|
|
|
adminAgain <- e2ePost mgr port "/api/login" (loginBody "admin" "s3cret") []
|
|
|
|
|
|
let authAgain = [("Cookie", fromMaybe "" (e2eCookie adminAgain))]
|
|
|
|
|
|
reset <- e2eSend mgr port "PATCH" "/api/tables/__chusql_users/rows/alice" "{\"values\":{\"password\":\"alice-new-password\"}}" (("X-ChuSQL-Database", "system") : authAgain)
|
|
|
|
|
|
statusCode (responseStatus reset) `shouldBe` 200
|
|
|
|
|
|
stopStorageProcess sp
|
|
|
|
|
|
(_, _, _, process) <- createProcess (proc serverBin ["--config", cfg]){std_out = NoStream, std_err = NoStream}
|
|
|
|
|
|
let restarted = StorageProcess process (spPipe sp)
|
|
|
|
|
|
bracket (pure restarted) stopStorageProcess $ \_ -> do
|
|
|
|
|
|
waitForStorage 200 >>= (`shouldBe` True)
|
|
|
|
|
|
backend2 <- ipcBackend
|
|
|
|
|
|
sessions2 <- newSessionStore getCurrentTime defaultSessionPolicy
|
|
|
|
|
|
limiter2 <- newRateLimiter getCurrentTime 50 300
|
|
|
|
|
|
env2 <- newAppEnv backend2 sessions2 testCredential limiter2 staticDir
|
|
|
|
|
|
app2 <- webApp env2
|
|
|
|
|
|
Warp.testWithApplication (pure app2) $ \port -> do
|
|
|
|
|
|
mgr <- newManager defaultManagerSettings
|
|
|
|
|
|
fresh <- e2ePost mgr port "/api/login" (loginBody "alice" "alice-new-password") []
|
|
|
|
|
|
statusCode (responseStatus fresh) `shouldBe` 204
|
|
|
|
|
|
stale <- e2ePost mgr port "/api/login" (loginBody "alice" "alice-password") []
|
|
|
|
|
|
statusCode (responseStatus stale) `shouldBe` 401
|
|
|
|
|
|
root <- e2ePost mgr port "/api/login" (loginBody "admin" "s3cret") []
|
|
|
|
|
|
statusCode (responseStatus root) `shouldBe` 204
|
|
|
|
|
|
|
|
|
|
|
|
-- | 找构建好的 csql 可执行文件;没构建出可执行文件就返回 Nothing
|
|
|
|
|
|
locateCliExe :: IO (Maybe FilePath)
|
|
|
|
|
|
locateCliExe = do
|
|
|
|
|
|
let name = platformBinaryName os "csql"
|
|
|
|
|
|
firstFile . concat =<< mapM (\root -> findFileUnder 8 (root </> ".stack-work") name) cliRoots
|
|
|
|
|
|
|
|
|
|
|
|
-- | csql 可能落地的根:测试的 CWD 是 chusql-web/,所以两种相对位置都试
|
|
|
|
|
|
cliRoots :: [FilePath]
|
|
|
|
|
|
cliRoots = ["", "chusql-cli", ".." </> "chusql-cli", "chusql-web", ".." </> "chusql-web", ".."]
|
|
|
|
|
|
|
|
|
|
|
|
-- | 按名字在 .stack-work 里递进找可执行文件。
|
|
|
|
|
|
-- stack 会把它放在带平台、包哈希与编译器版本的多层目录里,写死路径会过期。
|
|
|
|
|
|
findFileUnder :: Int -> FilePath -> FilePath -> IO [FilePath]
|
|
|
|
|
|
findFileUnder depth dir name = do
|
|
|
|
|
|
exists <- doesDirectoryExist dir
|
|
|
|
|
|
if not exists || depth <= 0
|
|
|
|
|
|
then pure []
|
|
|
|
|
|
else do
|
|
|
|
|
|
entries <- System.Directory.listDirectory dir
|
|
|
|
|
|
let (named, rest) = partition (== name) entries
|
|
|
|
|
|
deeper <- mapM (\entry -> findFileUnder (depth - 1) (dir </> entry) name) (filter diggable rest)
|
|
|
|
|
|
pure ([dir </> entry | entry <- named] ++ concat deeper)
|
|
|
|
|
|
where
|
|
|
|
|
|
-- 只往可能放可执行文件的目录里钻,跳过编译中间产物与包索引
|
|
|
|
|
|
diggable entry =
|
|
|
|
|
|
not ("." `isPrefixOf` entry)
|
|
|
|
|
|
&& entry `notElem` ["work", "tmp", "package-index", "snapshots", "pantry", "indices"]
|
|
|
|
|
|
|
|
|
|
|
|
-- | 一次端到端用例用的管名后缀:同一进程内唯一
|
|
|
|
|
|
uniqueSuffix :: IO String
|
|
|
|
|
|
uniqueSuffix = do
|
|
|
|
|
|
u <- newUnique
|
|
|
|
|
|
pure (show (hashUnique u))
|
|
|
|
|
|
|
|
|
|
|
|
-- | 造一份临时 chusql.toml([server] pipe_name + [storage] data_dir),路径由调用方显式交给存储进程
|
|
|
|
|
|
makeServerConfig :: String -> IO (String, FilePath, FilePath)
|
|
|
|
|
|
makeServerConfig label = do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
suffix <- uniqueSuffix
|
|
|
|
|
|
let pipe = "chusql-web-" ++ label ++ "-" ++ suffix
|
|
|
|
|
|
dataDir = tmp </> pipe
|
|
|
|
|
|
cfg = dataDir ++ ".toml"
|
|
|
|
|
|
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
|
|
|
|
|
slashed = map (\c -> if c == '\\' then '/' else c) dataDir
|
|
|
|
|
|
-- 临时路径在不同轮次可能重名,先扫干净,别把上一次的库带进来
|
|
|
|
|
|
removePathForcibly cfg
|
|
|
|
|
|
removePathForcibly dataDir
|
|
|
|
|
|
createDirectoryIfMissing True dataDir
|
|
|
|
|
|
writeFile
|
|
|
|
|
|
cfg
|
|
|
|
|
|
( unlines
|
|
|
|
|
|
[ "[server]"
|
|
|
|
|
|
, "pipe_name = \"" ++ pipe ++ "\""
|
|
|
|
|
|
, "[storage]"
|
|
|
|
|
|
, "data_dir = \"" ++ slashed ++ "\""
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
setPipeName pipe
|
|
|
|
|
|
pure (pipe, dataDir, cfg)
|
|
|
|
|
|
|
|
|
|
|
|
-- | 给 CLI 子进程造一份临时 chusql.toml:管名与 root 凭据都在里面,返回配置文件路径
|
|
|
|
|
|
makeCliHome :: Int -> String -> String -> String -> IO FilePath
|
|
|
|
|
|
makeCliHome port pipe user password = do
|
|
|
|
|
|
tmp <- getTemporaryDirectory
|
|
|
|
|
|
let home = tmp </> ("chusql-cli-" ++ show port)
|
|
|
|
|
|
cfg = home </> "chusql.toml"
|
|
|
|
|
|
createDirectoryIfMissing True home
|
|
|
|
|
|
writeFile
|
|
|
|
|
|
cfg
|
|
|
|
|
|
( unlines
|
|
|
|
|
|
[ "[server]"
|
|
|
|
|
|
, "pipe_name = \"" ++ pipe ++ "\""
|
|
|
|
|
|
, "[web]"
|
|
|
|
|
|
, "user = \"" ++ user ++ "\""
|
|
|
|
|
|
, "password = \"" ++ password ++ "\""
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
pure cfg
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 找存储进程,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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
[ storageDir </> "target" </> "debug" </> platformBinaryName os "chusql-storage"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
]
|
|
|
|
|
|
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
|