{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} module Main (main) where 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) 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, deleteSession, hashLooksValid, hashPasswordWith, lookupSession, newSessionStore, sessionPolicy, sessionToken, setSessionPolicy, verifyPassword, ) import ChuSQL.Web.Backend (beAccounts, columnsFromStatement, inDatabase, ipcBackend, memoryBackend, tableInfoOf) import ChuSQL.Web.Config ( WebConfig (..), canonicalSettingKeys, defaultPassword, defaultUser, defaultWebConfig, loadWebConfigAt, resolveCredential, resolvePipeName, 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) import ChuSQL.Web.StorageProcess (StorageProcess (..), platformBinaryName, startStorageProcess, storageChildArgs, stopStorageProcess, waitForStorage) import ChuSQL.Web.TOML (defaultConfigFile, resolveConfigPath) import ChuSQL.Web.UISettings ( readUISettings, uiSettingsFileCandidates, validateUISettings, writeUISettings, ) import Control.Concurrent.MVar (newMVar) import Control.Exception (IOException, bracket, finally, try) import Control.Monad (forM_) 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, isPrefixOf, nub, partition, 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 Data.Unique (hashUnique, newUnique) 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 (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, getXdgDirectory, removePathForcibly) import qualified System.Directory import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..)) import System.FilePath (()) import System.IO (Handle) import System.Info (os) import System.Process ( CreateProcess (cwd, std_err, std_out), ProcessHandle, StdStream (..), createProcess, proc, readCreateProcessWithExitCode, waitForProcess, ) import Test.Hspec import qualified System.Process as Process import Test.Hspec.Wai hiding (pendingWith) -- 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 -- | 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 = freshWorldWith staticDir testCredential -- | 同上,但换一个 root 凭据(免密用例用) freshWorldWith :: FilePath -> Credential -> IO AppEnv freshWorldWith staticDir credential = do db <- newMVar testDb clock <- newIORef epoch sessions <- newSessionStore (readIORef clock) policy limiter <- newRateLimiter (readIORef clock) 5 (5 * 60) env <- newAppEnv (memoryBackend testDatabaseName db) sessions credential limiter staticDir path <- tempSettingsPath "default" removeIfExists path uiPath <- tempUISettingsPath "default" removeIfExists uiPath 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) -- | 临时设置文件(每个用例组一个名字,互不干扰) 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)), testDatabaseHeader] -- | 把请求指到系统库 system(账号表与 system 库本身都在那里) systemHeaders :: [Header] -> [Header] systemHeaders hs = ("X-ChuSQL-Database", "system") : filter ((/= "X-ChuSQL-Database") . fst) hs -- | 带 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 (fixtureHeaders headers) "" -- | 带请求头的 JSON POST postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse postWith headers path payload = request methodPost path (fixtureHeaders (("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 (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) "" -- | 在 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 -- | 状态码断言,失败时把响应体和状态码一起报出来(定位用) 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) ) -- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉) 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 staticAssetSpec staticServeSpec authSpec staticDir passwordlessSpec staticDir catalogSpec staticDir limitsSpec staticDir oneClickSpec staticDir sqlSpec staticDir privilegesSpec 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 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 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 ()) 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 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")) 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" `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 "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 "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 tmp <- getTemporaryDirectory 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 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 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"] 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 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"] 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 "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 defaultUser `shouldBe` "root" usingDefaultCredentials Map.empty `shouldBe` True cred <- resolveCredential defaultWebConfig Map.empty credUser cred `shouldBe` defaultUser verifyPassword (credEncoded cred) defaultPassword `shouldBe` True it "a plain password in the settings file wins" $ do let saved = Map.singleton "password" "s3cret" usingDefaultCredentials saved `shouldBe` False cred <- resolveCredential defaultWebConfig saved verifyPassword (credEncoded cred) "s3cret" `shouldBe` True verifyPassword (credEncoded cred) defaultPassword `shouldBe` False 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 let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash" 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")]) credUser cred `shouldBe` "tester" 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 describe "Config: built-in defaults" $ do it "port defaults to 7777 and the administrator name is not baked into the config" $ do wcPort defaultWebConfig `shouldBe` 7777 wcHost defaultWebConfig `shouldBe` "127.0.0.1" wcUser defaultWebConfig `shouldBe` "" 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 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 withEnv [ ("CHUSQL_WEB_PORT", "8123") , ("CHUSQL_WEB_HOST", "0.0.0.0") , ("CHUSQL_WEB_STATIC", "mysite") , ("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 <- 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 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"] staticSpec :: FilePath -> Spec staticSpec staticDir = describe "Static files" $ do it "an unreadable file gives Nothing" $ readStatic staticDir "nope.js" `shouldReturn` Nothing -- | 手写静态前端:源文件入库,没有打包器,也不该再有 Node 工具链 staticAssetSpec :: Spec staticAssetSpec = describe "Static front end (hand-written, no bundler)" $ do let dir = "static" asset name = dir name readAsset name = readUtf8 (asset name) it "the app shell is present and loads both assets from /static/" $ do html <- readAsset "index.html" html `shouldSatisfy` T.isInfixOf "id=\"root\"" html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\"" html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\"" -- 服务端把 nonce 元信息插在 前面,这个标记不能丢 html `shouldSatisfy` T.isInfixOf "" it "the shell has no inline script or style (the CSP allows neither)" $ do html <- readAsset "index.html" html `shouldSatisfy` (not . T.isInfixOf "