{-# LANGUAGE OverloadedStrings #-} module Main (main) where import ChuSQL.Model (Column (..), Database, Table (..), Value (..)) import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..)) import ChuSQL.Syntax.AST (Expr (..), FromClause (..), Statement (..)) import ChuSQL.Web.Actions ( ColumnSpec (..), CreateTableSpec (..), coerceValue, columnTypeOf, createIndexSql, createTableSql, deleteRowSql, dropIndexSql, dropTableSql, insertRowSql, insertRowsSql, isIdentifier, selectRowsSql, sqlLiteral, updateRowSql, ) import ChuSQL.Web.Api ( AppEnv (..), Live (..), defaultLive, newAppEnv, parseCookieHeader, readLive, renderRow, setLive, webApp, ) import ChuSQL.Web.Auth ( Credential (..), SessionPolicy (..), createSession, defaultSessionPolicy, deleteOtherSessions, deleteSession, hashLooksValid, hashPasswordWith, lookupSession, newSessionStore, sessionPolicy, sessionToken, setSessionPolicy, verifyPassword, ) import ChuSQL.Web.Backend (columnsFromStatement, ipcBackend, memoryBackend, tableInfoOf) import ChuSQL.Web.Config ( WebConfig (..), defaultPassword, defaultUser, defaultWebConfig, loadWebConfig, resolveCredential, usingDefaultCredentials, ) import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit) import ChuSQL.Web.Settings ( SettingItem (..), applySettings, defaultOf, effectiveSettings, findItem, isRestartRequired, isRootOnly, liveKeys, readSettingsFile, resolveSettingsFile, settingCatalogue, writeSettingsFile, ) import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) import ChuSQL.Web.StorageProcess (StorageProcess (..), startStorageProcess, stopStorageProcess) import ChuSQL.Web.UiSettings ( readUiSettings, resolveUiSettingsFile, uiSettingsFileCandidates, validateUiSettings, writeUiSettings, ) import Control.Concurrent.MVar (newMVar) import Control.Exception (IOException, bracket, finally, try) import Data.Aeson (decode) import qualified Data.Aeson as A import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Lazy as BL import Data.IORef (newIORef, readIORef, writeIORef) import Data.List (isInfixOf, isSuffixOf, nub, sort) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime) import qualified Data.Vector as V import Network.HTTP.Client ( Manager, Request (method, requestBody, requestHeaders), RequestBody (..), Response (responseBody, responseHeaders, responseStatus), defaultManagerSettings, httpLbs, newManager, parseRequest, ) import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode) import Network.Wai (Application) import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Test as WT import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory) import qualified System.Directory import System.Environment (lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..)) import System.FilePath ((>)) import System.IO (Handle) import System.Process ( CreateProcess (cwd, std_err, std_out), ProcessHandle, StdStream (..), createProcess, proc, waitForProcess, ) import Test.Hspec import Test.Hspec.Wai hiding (pendingWith) -- ChuSQL Web 测试,覆盖三层: -- 纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。 -- | users 5 行 + orders 1 行 testDb :: Database testDb = [ ( "users" , Table "users" [("id", TInt), ("name", TStr), ("age", TInt)] [ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (20 + i))] | i <- [1 .. 5] ] ) , ( "orders" , Table "orders" [("id", TInt), ("user_id", TInt)] [[("id", VInt 1), ("user_id", VInt 1)]] ) ] -- | 测试账号:迭代次数压低(跑得快),口令 `s3cret` testCredential :: Credential testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret") -- | 可控时钟的起点 epoch :: UTCTime epoch = UTCTime (fromGregorian 2026 1 1) 0 -- | 默认会话策略 policy :: SessionPolicy policy = SessionPolicy (8 * 3600) (24 * 3600) -- | 造一条 `SELECT *` selectStar :: FromClause -> Statement selectStar from = Select ["*"] from Nothing [] Nothing -- | 造一条显式列名的 SELECT selectThese :: [String] -> FromClause -> Statement selectThese cols from = Select cols from Nothing [] Nothing -- | with 块里的会话类型别名 type TestSession a = WaiSession () a -- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录 freshWorld :: FilePath -> IO AppEnv freshWorld staticDir = do db <- newMVar testDb clock <- newIORef epoch sessions <- newSessionStore (readIORef clock) policy limiter <- newRateLimiter (readIORef clock) 5 (5 * 60) env <- newAppEnv (memoryBackend db) sessions testCredential limiter staticDir path <- tempSettingsPath "default" removeIfExists path uiPath <- tempUiSettingsPath "default" removeIfExists uiPath pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath, aeEffective = Map.empty} -- | 临时设置文件(每个用例组一个名字,互不干扰) tempSettingsPath :: String -> IO FilePath tempSettingsPath name = do tmp <- getTemporaryDirectory pure (tmp > ("chusql-web-test-" ++ name ++ "-settings.json")) -- | 临时 IDE 设置文件(同样按用例组取名) tempUiSettingsPath :: String -> IO FilePath tempUiSettingsPath name = do tmp <- getTemporaryDirectory pure (tmp > ("chusql-web-test-" ++ name ++ "-ui-settings.json")) -- | 文件不存在也算成功 removeIfExists :: FilePath -> IO () removeIfExists path = do there <- doesFileExist path if there then removeFileSafe path else pure () where removeFileSafe p = do _ <- try (System.Directory.removeFile p) :: IO (Either IOException ()) pure () -- | 带自己的设置文件的用例组 freshWorldAt :: FilePath -> String -> IO AppEnv freshWorldAt staticDir name = do env <- freshWorld staticDir path <- tempSettingsPath name removeIfExists path uiPath <- tempUiSettingsPath name removeIfExists uiPath pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath} -- | 临时静态目录,内容固定便于断言 makeStaticDir :: IO FilePath makeStaticDir = do tmp <- getTemporaryDirectory let dir = tmp > "chusql-web-static-test" createDirectoryIfMissing True dir BS.writeFile (dir > "index.html") "
ChuSQL Console" BS.writeFile (dir > "app.js") "// test bundle\n" BS.writeFile (dir > "style.css") "body{}\n" BS.writeFile (dir > "secret.token") "do-not-serve\n" pure dir -- | 一个用例组一套环境 withWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec withWorld staticDir body = do appEnv <- runIO (freshWorld staticDir) with (webApp appEnv) (body appEnv) -- | 同上,但带着自己的设置文件(改设置/改口令的用例不互相串) withWorldAt :: FilePath -> String -> (AppEnv -> SpecWith ((), Application)) -> Spec withWorldAt staticDir name body = do appEnv <- runIO (freshWorldAt staticDir name) with (webApp appEnv) (body appEnv) -- | 响应体解成 JSON jsonBody :: WT.SResponse -> A.Value jsonBody res = fromMaybe A.Null (decode (WT.simpleBody res)) -- | 取字段 field :: Text -> A.Value -> Maybe A.Value field name (A.Object o) = KM.lookup (K.fromText name) o field _ _ = Nothing -- | 取字段(没有给 Null) at :: Text -> A.Value -> A.Value at name value = fromMaybe A.Null (field name value) -- | 当数组看 items :: A.Value -> [A.Value] items (A.Array xs) = V.toList xs items _ = [] -- | 当文本看 asText :: A.Value -> Text asText (A.String t) = t asText _ = "" -- | 当整数看 asInt :: A.Value -> Int asInt (A.Number n) = round n asInt _ = -1 -- | 第 i 个元素(越界给 Nothing,不用 `!!`) nth :: Int -> [a] -> Maybe a nth i xs = case drop i xs of (x : _) -> Just x [] -> Nothing -- | 取 Set-Cookie 里的会话令牌 sessionCookieOf :: WT.SResponse -> Maybe Text sessionCookieOf res = do raw <- lookup "Set-Cookie" (WT.simpleHeaders res) parseCookieHeader "chusql_session" (TE.decodeUtf8 raw) -- | 会话令牌变请求头 cookieHeaders :: WT.SResponse -> [Header] cookieHeaders res = case sessionCookieOf res of Nothing -> [] Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token))] -- | 带 JSON 体的请求 jsonRequest :: Method -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse jsonRequest httpMethod urlPath payload = request httpMethod urlPath [("Content-Type", "application/json")] payload -- | 登录请求体 loginBody :: Text -> Text -> BL.ByteString loginBody user password = BL.fromStrict . TE.encodeUtf8 $ T.concat ["{\"user\":\"", user, "\",\"password\":\"", password, "\"}"] -- | 登录 loginAs :: Text -> Text -> TestSession WT.SResponse loginAs user password = jsonRequest methodPost "/api/login" (loginBody user password) -- | 带请求头的 GET getWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse getWith headers path = request methodGet path headers "" -- | 带请求头的 JSON POST postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse postWith headers path payload = request methodPost path (("Content-Type", "application/json") : headers) payload -- | 带请求头的 JSON PUT(改设置用的是 PUT) putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse putWith headers path payload = request "PUT" path (("Content-Type", "application/json") : headers) payload -- | 在 items 数组里按键找一项(找不到给 Null) headEntry :: Text -> [A.Value] -> A.Value headEntry key entries = case [e | e <- entries, asText (at "key" e) == key] of (e : _) -> e [] -> A.Null -- | 纯断言抬进 WaiSession check :: IO () -> TestSession () check = liftIO -- | 已经拿到响应对象时的状态码断言 hasStatus :: WT.SResponse -> Int -> IO () hasStatus res code = statusCode (WT.simpleStatus res) `shouldBe` code -- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉) withEnv :: [(String, String)] -> IO a -> IO a withEnv kvs act = do saved <- mapM (\(k, _) -> fmap ((,) k) (lookupEnv k)) kvs mapM_ (uncurry setEnv) kvs act `finally` mapM_ restore saved where restore (k, mv) = maybe (unsetEnv k) (setEnv k) mv -- | 按 UTF-8 读文件(避免解码错) readUtf8 :: FilePath -> IO Text readUtf8 path = TE.decodeUtf8 <$> BS.readFile path -- | Either 判空 isLeftE :: Either a b -> Bool isLeftE (Left _) = True isLeftE (Right _) = False -- | Either 判成 isRightE :: Either a b -> Bool isRightE = either (const False) (const True) -- | 响应体里第一行(表格数据) firstRow :: WT.SResponse -> [A.Value] firstRow res = case items (at "rows" (jsonBody res)) of (r : _) -> case r of A.Array xs -> V.toList xs _ -> [] [] -> [] -- | 数组字段的长度 arrayLen :: A.Value -> Int arrayLen = length . items main :: IO () main = hspec spec spec :: Spec spec = do staticDir <- runIO makeStaticDir unitSpec actionsSpec staticSpec staticDir frontendBundleSpec authSpec staticDir catalogSpec staticDir limitsSpec staticDir oneClickSpec staticDir sqlSpec staticDir settingsSpec staticDir uiSettingsSpec staticDir sortSpec staticDir securitySpec staticDir e2eSpec staticDir unitSpec :: Spec unitSpec = do describe "Password hashing" $ do it "accepts the correct password" $ verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "s3cret" `shouldBe` True it "rejects a wrong password" $ verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "wrong" `shouldBe` False it "treats a broken encoded hash as mismatch instead of throwing" $ verifyPassword "garbage" "s3cret" `shouldBe` False it "same password with different salts yields different hashes" $ hashPasswordWith 1000 (BS.replicate 16 1) "s3cret" `shouldNotBe` hashPasswordWith 1000 (BS.replicate 16 2) "s3cret" it "encoded hash has four parts: algo$iterations$salt$hash" $ T.splitOn "$" (hashPasswordWith 1000 (BS.replicate 16 3) "s3cret") `shouldSatisfy` ((== 4) . length) describe "Sessions" $ do it "token is 64 hex chars and differs between calls" $ do a <- sessionToken b <- sessionToken T.length a `shouldBe` 64 T.all (`elem` ("0123456789abcdef" :: String)) a `shouldBe` True a `shouldNotBe` b it "idle timeout drops the session" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) policy token <- createSession store "admin" fresh <- lookupSession store token fresh `shouldBe` Just "admin" writeIORef clock (addUTCTime (9 * 3600) epoch) stale <- lookupSession store token stale `shouldBe` Nothing it "activity refreshes the idle timer but the absolute limit still expires" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) policy token <- createSession store "admin" writeIORef clock (addUTCTime (7 * 3600) epoch) seen <- lookupSession store token seen `shouldBe` Just "admin" writeIORef clock (addUTCTime (13 * 3600) epoch) seenAgain <- lookupSession store token seenAgain `shouldBe` Just "admin" writeIORef clock (addUTCTime (25 * 3600) epoch) gone <- lookupSession store token gone `shouldBe` Nothing it "deleted session is no longer found" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) policy token <- createSession store "admin" deleteSession store token found <- lookupSession store token found `shouldBe` Nothing it "a new expiry policy takes effect immediately (settings hot reload path)" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) (SessionPolicy (8 * 3600) (24 * 3600)) token <- createSession store "admin" setSessionPolicy store (SessionPolicy 10 20) now <- sessionPolicy store spIdleSeconds now `shouldBe` 10 writeIORef clock (addUTCTime 11 epoch) gone <- lookupSession store token gone `shouldBe` Nothing it "password change drops other sessions but keeps the current one" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) policy keep <- createSession store "admin" other <- createSession store "admin" removed <- deleteOtherSessions store keep kept <- lookupSession store keep dropped <- lookupSession store other removed `shouldBe` 1 kept `shouldBe` Just "admin" dropped `shouldBe` Nothing it "no other sessions means zero removed (visible in logs)" $ do clock <- newIORef epoch store <- newSessionStore (readIORef clock) policy keep <- createSession store "admin" removed <- deleteOtherSessions store keep removed `shouldBe` 0 stillThere <- lookupSession store keep stillThere `shouldBe` Just "admin" describe "Login rate limiting" $ do it "below the limit is not blocked" $ do clock <- newIORef epoch rl <- newRateLimiter (readIORef clock) 3 60 rateLimitRecord rl "admin" rateLimitRecord rl "admin" open <- rateLimitBlock rl "admin" open `shouldBe` Nothing it "hitting the limit blocks and returns seconds to wait" $ do clock <- newIORef epoch rl <- newRateLimiter (readIORef clock) 3 60 mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] blocked <- rateLimitBlock rl "admin" blocked `shouldSatisfy` maybe False (> 0) it "window expiry allows retries again" $ do clock <- newIORef epoch rl <- newRateLimiter (readIORef clock) 3 60 mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] writeIORef clock (addUTCTime 61 epoch) open <- rateLimitBlock rl "admin" open `shouldBe` Nothing it "raising the threshold unlocks immediately (settings hot reload)" $ do clock <- newIORef epoch rl <- newRateLimiter (readIORef clock) 3 60 mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] blocked <- rateLimitBlock rl "admin" blocked `shouldSatisfy` maybe False (> 0) setRateLimit rl 10 60 unlocked <- rateLimitBlock rl "admin" unlocked `shouldBe` Nothing it "shortening the window allows immediately (settings hot reload)" $ do clock <- newIORef epoch rl <- newRateLimiter (readIORef clock) 3 3600 mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] setRateLimit rl 3 1 writeIORef clock (addUTCTime 2 epoch) open <- rateLimitBlock rl "admin" open `shouldBe` Nothing describe "Cookie parsing" $ do it "picks the session token out of many cookies" $ parseCookieHeader "chusql_session" "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123" it "missing key yields Nothing" $ parseCookieHeader "chusql_session" "a=1; b=2" `shouldBe` Nothing it "empty value does not count as a value" $ parseCookieHeader "chusql_session" "chusql_session=" `shouldBe` Nothing describe "Password hash validation (checked before changing the password)" $ do it "self-computed hash is valid" $ hashLooksValid (hashPasswordWith 1000 (BS.replicate 16 5) "s3cret") `shouldBe` True it "broken hashes are invalid (bad length/algo/parts)" $ do hashLooksValid "garbage" `shouldBe` False hashLooksValid "pbkdf2-sha256$1000$00$00" `shouldBe` False hashLooksValid "md5$1000$00$00" `shouldBe` False describe "Settings catalogue (shared by the settings page and the server)" $ do it "keys are unique and kind/default are filled" $ do let keys = map siKey settingCatalogue length keys `shouldBe` length (nub keys) all (not . T.null . siKey) settingCatalogue `shouldBe` True all (not . T.null . siLabel) settingCatalogue `shouldBe` True all (\i -> siKind i `elem` ["text", "int", "bool", "secret"]) settingCatalogue `shouldBe` True let machineReadable = [i | i <- settingCatalogue, siKind i `elem` ["int", "bool"]] machineReadable `shouldSatisfy` (not . null) all (not . T.null . siDefault) machineReadable `shouldBe` True [siDefault i | i <- settingCatalogue, siKind i == "secret"] `shouldSatisfy` all T.null let envs = map siEnv settingCatalogue all (not . T.null) envs `shouldBe` True length envs `shouldBe` length (nub envs) it "every item has an env var and can be found by key" $ do all (not . T.null . siEnv) settingCatalogue `shouldBe` True map (fmap siKey . findItem . siKey) settingCatalogue `shouldBe` map (Just . siKey) settingCatalogue it "critical settings are root-only, tuning ones are not" $ do isRootOnly "port" `shouldBe` True isRootOnly "host" `shouldBe` True isRootOnly "user" `shouldBe` True isRootOnly "password-hash" `shouldBe` True isRootOnly "rows-per-page" `shouldBe` False isRootOnly "body-limit" `shouldBe` False it "live keys exist in the catalogue and are all integers" $ do liveKeys `shouldNotSatisfy` null map findItem liveKeys `shouldSatisfy` all (/= Nothing) [siKind i | Just i <- map findItem liveKeys] `shouldSatisfy` all (== "int") it "restart and live flags do not conflict: port needs a restart, page size does not" $ do isRestartRequired "port" `shouldBe` True isRestartRequired "static-dir" `shouldBe` True isRestartRequired "rows-per-page" `shouldBe` False filter isRestartRequired liveKeys `shouldBe` [] it "defaults are found, an unknown key gives an empty string" $ do defaultOf "port" `shouldBe` "7777" defaultOf "rows-per-page" `shouldBe` "25" defaultOf "nope" `shouldBe` "" it "the flattened startup config carries the real port/account" $ do let flat = effectiveSettings defaultWebConfig Map.lookup "port" flat `shouldBe` Just "7777" Map.lookup "user" flat `shouldBe` Just defaultUser Map.lookup "rows-per-page" flat `shouldBe` Just "25" Map.member "password" flat `shouldBe` False Map.member "password-hash" flat `shouldBe` False it "merge settings: known keys applied, unknown rejected, empty clears" $ do let merged = applySettings Map.empty (Map.fromList [("rows-per-page", "50"), ("port", "9000")]) fmap (Map.lookup "rows-per-page") merged `shouldBe` Right (Just "50") applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE fmap (Map.lookup "port") (applySettings (Map.fromList [("port", "9000")]) (Map.fromList [("port", "")])) `shouldBe` Right (Just "") it "type validation: ints reject letters, bools accept only known spellings" $ do applySettings Map.empty (Map.fromList [("rows-per-page", "abc")]) `shouldSatisfy` isLeftE applySettings Map.empty (Map.fromList [("rows-per-page", "10")]) `shouldSatisfy` isRightE applySettings Map.empty (Map.fromList [("seed", "maybe")]) `shouldSatisfy` isLeftE applySettings Map.empty (Map.fromList [("seed", "on")]) `shouldSatisfy` isRightE it "write settings file: empty values are not stored and reads come back" $ do tmp <- getTemporaryDirectory let path = tmp > "chusql-web-test-write-settings.json" removeIfExists path written <- writeSettingsFile path (Map.fromList [("port", "9000"), ("host", "")]) written `shouldBe` Right () stored <- readSettingsFile path Map.lookup "port" stored `shouldBe` Just "9000" Map.member "host" stored `shouldBe` False it "write settings file: reports failure when the directory cannot be created" $ do tmp <- getTemporaryDirectory let blocker = tmp > "chusql-web-test-blocker" removeIfExists blocker BS.writeFile blocker "not a directory" failed <- writeSettingsFile (blocker > "settings.json") (Map.fromList [("port", "1")]) failed `shouldSatisfy` isLeftE removeIfExists blocker it "an unreadable file counts as empty config (not a crash)" $ do tmp <- getTemporaryDirectory missing <- readSettingsFile (tmp > "chusql-web-test-does-not-exist.json") missing `shouldBe` Map.empty it "settings file lookup: uses an existing file" $ do tmp <- getTemporaryDirectory let web = tmp > "chusql-web-test-resolve" createDirectoryIfMissing True (web > "script") BS.writeFile (web > "script" > "chusql.settings.json") "{\"port\":\"9000\"}" System.Directory.withCurrentDirectory web $ do found <- resolveSettingsFile found `shouldBe` ("script" > "chusql.settings.json") it "settings file lookup: picks the candidate in an existing dir when none exists" $ do tmp <- getTemporaryDirectory let root = tmp > "chusql-web-test-resolve-root" let web = root > "chusql-web" createDirectoryIfMissing True (root > "script") createDirectoryIfMissing True web System.Directory.withCurrentDirectory web $ do found <- resolveSettingsFile found `shouldBe` (".." > "script" > "chusql.settings.json") describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do it "no sort and no filter yields a plain SELECT" $ selectRowsSql "users" Nothing [] `shouldBe` Right "SELECT * FROM users" it "column and direction produce ORDER BY" $ do selectRowsSql "users" (Just ("age", True)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age ASC" selectRowsSql "users" (Just ("age", False)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age DESC" it "equality filters become WHERE with AND, placed before ORDER BY" $ do selectRowsSql "users" Nothing [("name", VStr "Ann"), ("age", VInt 30)] `shouldBe` Right "SELECT * FROM users WHERE name = 'Ann' AND age = 30" selectRowsSql "users" (Just ("age", False)) [("name", VStr "O'Brien")] `shouldBe` Right "SELECT * FROM users WHERE name = 'O''Brien' ORDER BY age DESC" selectRowsSql "users" Nothing [("vip", VBool True)] `shouldBe` Right "SELECT * FROM users WHERE vip = TRUE" it "table and column names go through the whitelist (including filter columns)" $ do selectRowsSql "users; DROP TABLE users" Nothing [] `shouldSatisfy` isLeftE selectRowsSql "users" (Just ("age; DROP TABLE users", True)) [] `shouldSatisfy` isLeftE selectRowsSql "users" Nothing [("age; DROP TABLE users", VInt 1)] `shouldSatisfy` isLeftE describe "Identifier whitelist" $ do it "normal table names pass" $ do isIdentifier "users" `shouldBe` True isIdentifier "user_1" `shouldBe` True isIdentifier "_tmp" `shouldBe` True it "leading digit / space / semicolon / empty string are rejected" $ do isIdentifier "1users" `shouldBe` False isIdentifier "user name" `shouldBe` False isIdentifier "users;DROP TABLE users" `shouldBe` False isIdentifier "" `shouldBe` False it "over 64 characters is rejected" $ isIdentifier (T.replicate 65 "a") `shouldBe` False describe "Static file name whitelist" $ do it "normal file names pass" $ do safeRelative "app.js" `shouldBe` Just "app.js" safeRelative "style.css" `shouldBe` Just "style.css" safeRelative "codicon.ttf" `shouldBe` Just "codicon.ttf" safeRelative "editor.worker-a1.js" `shouldBe` Just "editor.worker-a1.js" it "traversal / subdirectory / empty string are all rejected" $ do safeRelative "../secret" `shouldBe` Nothing safeRelative "sub/app.js" `shouldBe` Nothing safeRelative "a\\b" `shouldBe` Nothing safeRelative "" `shouldBe` Nothing safeRelative "a b.js" `shouldBe` Nothing it "disallowed extensions fail (.token / .db / no extension)" $ do safeRelative "secret.token" `shouldBe` Nothing safeRelative "catalog.json" `shouldBe` Nothing safeRelative "data.db" `shouldBe` Nothing safeRelative "README" `shouldBe` Nothing describe "Content-Type" $ do it "chosen by extension (case-insensitive)" $ do contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8" contentTypeOf "app.JS" `shouldBe` "text/javascript; charset=utf-8" contentTypeOf "style.css" `shouldBe` "text/css; charset=utf-8" contentTypeOf "codicon.ttf" `shouldBe` "font/ttf" contentTypeOf "x.bin" `shouldBe` "application/octet-stream" describe "Column inference (headers even for empty results)" $ do it "SELECT * expands to the table columns" $ columnsFromStatement testDb (selectStar (FromTable Nothing "users")) `shouldBe` ["id", "name", "age"] it "aliased SELECT * prefixes the alias" $ columnsFromStatement testDb (selectStar (FromTable (Just "u") "users")) `shouldBe` ["u.id", "u.name", "u.age"] it "JOIN SELECT * puts the left table first" $ columnsFromStatement testDb (selectStar (FromJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "id") (Col "user_id")))) `shouldBe` ["id", "name", "age", "o.id", "o.user_id"] it "explicit column names are kept as-is" $ columnsFromStatement testDb (selectThese ["name"] (FromTable Nothing "users")) `shouldBe` ["name"] it "write statements have no result columns" $ columnsFromStatement testDb (DropTable "users") `shouldBe` [] describe "Row rendering" $ do it "values follow the given column order, missing columns give null" $ renderRow ["name", "id", "age"] [("id", VInt 1), ("name", VStr "a")] `shouldBe` [A.String "a", A.Number 1, A.Null] describe "In-memory backend catalogue" $ do it "row count, columns and stats all match" $ do case lookup "users" testDb of Nothing -> expectationFailure "testDb has no users" Just tbl -> do let info = tableInfoOf ("users", tbl) tiTable info `shouldBe` "users" tiRows info `shouldBe` 5 map scName (tiColumns info) `shouldBe` ["id", "name", "age"] tiStats info `shouldBe` [("id", 5, False), ("name", 5, False), ("age", 5, False)] describe "Default account and credential source" $ do it "the default account is root and uses the built-in demo password" $ do let cfg = defaultWebConfig defaultUser `shouldBe` "root" wcUser cfg `shouldBe` "root" usingDefaultCredentials cfg `shouldBe` True cred <- resolveCredential cfg credUser cred `shouldBe` defaultUser verifyPassword (credEncoded cred) defaultPassword `shouldBe` True it "a plain password wins and the default stops working" $ do let cfg = defaultWebConfig{wcPassword = Just "s3cret"} usingDefaultCredentials cfg `shouldBe` False cred <- resolveCredential cfg verifyPassword (credEncoded cred) "s3cret" `shouldBe` True verifyPassword (credEncoded cred) defaultPassword `shouldBe` False it "a provided hash is used directly (not re-hashed)" $ do let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash" cred <- resolveCredential defaultWebConfig{wcPasswordHash = Just encoded} credEncoded cred `shouldBe` encoded it "the account name can be changed" $ do cred <- resolveCredential defaultWebConfig{wcUser = "tester"} credUser cred `shouldBe` "tester" describe "Config: built-in defaults" $ do it "port defaults to 7777, account defaults to admin/chusql" $ do wcPort defaultWebConfig `shouldBe` 7777 wcHost defaultWebConfig `shouldBe` "127.0.0.1" wcUser defaultWebConfig `shouldBe` defaultUser wcCookieSecure defaultWebConfig `shouldBe` False it "session / rate limit / paging / row limits all have explicit defaults" $ do wcSessionIdle defaultWebConfig `shouldBe` 8 * 3600 wcSessionMax defaultWebConfig `shouldBe` 24 * 3600 wcLoginMaxAttempts defaultWebConfig `shouldBe` 5 wcLoginWindow defaultWebConfig `shouldBe` 5 * 60 wcPageSize defaultWebConfig `shouldBe` 25 wcMaxPageSize defaultWebConfig `shouldBe` 500 wcMaxRows defaultWebConfig `shouldBe` 1000 wcMaxSqlLength defaultWebConfig `shouldBe` 20000 wcBodyLimit defaultWebConfig `shouldBe` 65536 describe "Config: environment variables override defaults" $ do it "port / host / static dir / account" $ do withEnv [ ("CHUSQL_WEB_PORT", "8123") , ("CHUSQL_WEB_HOST", "0.0.0.0") , ("CHUSQL_WEB_STATIC", "mysite") , ("CHUSQL_WEB_USER", "tester") ] $ do cfg <- loadWebConfig wcPort cfg `shouldBe` 8123 wcHost cfg `shouldBe` "0.0.0.0" wcStaticDir cfg `shouldBe` "mysite" wcUser cfg `shouldBe` "tester" it "session / rate limit / paging / rows / body limit" $ do withEnv [ ("CHUSQL_WEB_SESSION_IDLE", "60") , ("CHUSQL_WEB_SESSION_MAX", "120") , ("CHUSQL_WEB_LOGIN_MAX_ATTEMPTS", "3") , ("CHUSQL_WEB_LOGIN_WINDOW", "30") , ("CHUSQL_WEB_PAGE_SIZE", "7") , ("CHUSQL_WEB_MAX_PAGE_SIZE", "70") , ("CHUSQL_WEB_MAX_ROWS", "9") , ("CHUSQL_WEB_MAX_SQL_LENGTH", "1234") , ("CHUSQL_WEB_BODY_LIMIT", "2048") , ("CHUSQL_WEB_COOKIE_SECURE", "1") ] $ do cfg <- loadWebConfig wcSessionIdle cfg `shouldBe` 60 wcSessionMax cfg `shouldBe` 120 wcLoginMaxAttempts cfg `shouldBe` 3 wcLoginWindow cfg `shouldBe` 30 wcPageSize cfg `shouldBe` 7 wcMaxPageSize cfg `shouldBe` 70 wcMaxRows cfg `shouldBe` 9 wcMaxSqlLength cfg `shouldBe` 1234 wcBodyLimit cfg `shouldBe` 2048 wcCookieSecure cfg `shouldBe` True it "unsetting the env var restores the default port 7777" $ do withEnv [("CHUSQL_WEB_PORT", "8123")] $ do tuned <- loadWebConfig wcPort tuned `shouldBe` 8123 restored <- loadWebConfig wcPort restored `shouldBe` 7777 describe "Launcher script (script/chusql.ps1)" $ do it "script/ has chusql.ps1 / chusql.cmd, default port 7777, covering all three modules" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") batch <- readUtf8 (".." > "script" > "chusql.cmd") batch `shouldSatisfy` T.isInfixOf "chusql.ps1" script `shouldSatisfy` T.isInfixOf "chusql-storage" script `shouldSatisfy` T.isInfixOf "chusql-web.exe" script `shouldSatisfy` T.isInfixOf "/api/status" script `shouldSatisfy` T.isInfixOf "7777" it "has web / cli / config commands, cli is a placeholder (dev notice + still starts web)" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") script `shouldSatisfy` T.isInfixOf "CLI interface is under development." script `shouldSatisfy` T.isInfixOf "'web'" script `shouldSatisfy` T.isInfixOf "'cli'" script `shouldSatisfy` T.isInfixOf "config set" it "settings persist to a file (config set/unset/reset + precedence note)" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") script `shouldSatisfy` T.isInfixOf "chusql.settings.json" script `shouldSatisfy` T.isInfixOf "command line > settings file > environment > default" script `shouldSatisfy` T.isInfixOf "Save-SavedSettings" it "child window hidden + logs flow back to the launcher window" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") script `shouldSatisfy` T.isInfixOf "-WindowStyle Hidden" script `shouldSatisfy` T.isInfixOf "Show-NewLogLines" script `shouldSatisfy` T.isInfixOf "RedirectStandardOutput" it "the script exposes settings as switches (port/session/paging/storage tuning)" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_SESSION_IDLE" script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_MAX_ROWS" script `shouldSatisfy` T.isInfixOf "CHUSQL_BUFFER_POOL_SIZE" script `shouldSatisfy` T.isInfixOf "CHUSQL_PAGE_SIZE" it "the Rust artifact is named chusql-storage (no longer server)" $ do script <- readUtf8 (".." > "script" > "chusql.ps1") script `shouldSatisfy` T.isInfixOf "target\\release\\chusql-storage.exe" script `shouldSatisfy` (not . T.isInfixOf "target\\release\\server.exe") staticSpec :: FilePath -> Spec staticSpec staticDir = describe "Static files" $ do it "reads index.html from the temp dir" $ do content <- readStatic staticDir "index.html" content `shouldSatisfy` maybe False (not . BS.null) it "an unreadable file gives Nothing" $ readStatic staticDir "nope.js" `shouldReturn` Nothing it "a traversal path is unreadable" $ readStatic staticDir "../secret.token" `shouldReturn` Nothing frontendBundleSpec :: Spec frontendBundleSpec = describe "React frontend build artifacts" $ do present <- runIO (doesDirectoryExist "static") let asset name = "static" > name readAsset name = readUtf8 (asset name) -- | static/ 是构建产物,没构建就跳过 check body | present = body | otherwise = pendingWith "static/ is not built; run npm run build under chusql-web/frontend" it "entry HTML exists and is non-empty" $ check $ do html <- BS.readFile (asset "index.html") html `shouldSatisfy` (not . BS.null) it "entry declares the Chinese page language" $ check $ do html <- readAsset "index.html" html `shouldSatisfy` T.isInfixOf "lang=\"zh-CN\"" it "entry loads the app script only from /static/" $ check $ do html <- readAsset "index.html" html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\"" it "entry loads styles only from /static/" $ check $ do html <- readAsset "index.html" html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\"" it "entry has no inline script" $ check $ do html <- readAsset "index.html" html `shouldSatisfy` (not . T.isInfixOf "