Files

2231 lines
136 KiB
Haskell
Raw Permalink Normal View History

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
module Main (main) where
2026-10-15 22:21:41 +08:00
import Data.Either (lefts)
2026-10-03 16:49:23 +08:00
import ChuSQL.Core.Engine.Storage.IPC (
Account (..),
2026-10-05 22:22:23 +08:00
Request (ReqAccountCreate, ReqAccountReset, ReqAccountsList, ReqIdentityAlter),
2026-10-03 21:03:17 +08:00
TableInfo (..),
2026-10-03 16:49:23 +08:00
closeConnection,
localStorageLink,
setStorageLink,
)
2026-10-03 19:21:02 +08:00
import ChuSQL.Core.Engine.Syntax.AST (Statement)
2026-10-11 17:32:12 +08:00
import ChuSQL.Interface.Normalization
import ChuSQL.Server.Normalization (normalizationBudget, normalizationReport, schemaFingerprint)
import ChuSQL.Core.Protocol (SchemaColumn (..))
2026-10-05 22:22:23 +08:00
import Data.IORef (newIORef, readIORef, writeIORef)
import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
2026-10-11 17:32:12 +08:00
import ChuSQL.Core.Model (Database, Histogram (..), Row, Table (..), TableIdentity (..), Value (..), pattern TInt, pattern TStr)
import ChuSQL.Interface.Auth (hashPasswordWith)
2026-10-11 17:32:12 +08:00
import ChuSQL.Interface.Protocol (ClientRequest (..), Grant (..), RoleView (..), ServerResponse (..), decodeRequest, encodeResponse, encodeRequest, decodeResponse)
import ChuSQL.Interface.RateLimit (newRateLimiter)
2026-10-05 22:22:23 +08:00
import ChuSQL.Interface.Link (connectClient, closeClient, clientSudoLogin, clientQuery)
import ChuSQL.Interface.Sudo (SudoCredential, newSudoCredential, sudoProof, verifySudoProof, validSudoChallenge,
createSudoCredential, removeSudoCredential, readSudoCredential, sudoPrivileged)
import ChuSQL.Server.Accounts (Principal (..), ensureRootAccount)
2026-10-03 16:49:23 +08:00
import ChuSQL.Server.Backend (Backend (..), StatementResult (..), ipcBackend, memoryBackend)
import ChuSQL.Server.Privileges (
PrivilegeCommand (..),
PrivilegeError (..),
Privileges,
2026-10-03 22:08:40 +08:00
affectedAccounts,
authorize,
2026-10-05 22:22:23 +08:00
authorizeConnect,
claimObject,
prepareObject,
listRoleViews,
2026-10-11 17:32:12 +08:00
listSelfGrants,
newPrivileges,
runPrivilegeCommand,
)
2026-10-05 22:22:23 +08:00
import ChuSQL.Server.Session (QueryResult (..), Session, SessionError (..), newSession, runStatementCoded, runStorageCoded, authenticateSessionCoded)
import ChuSQL.Server.TCP (
ServerConfig (..),
2026-10-05 22:22:23 +08:00
ServerEnv (..),
ServerHandle (..),
defaultServerConfig,
loadServerConfigAt,
newServerEnv,
startServer,
2026-10-05 22:22:23 +08:00
isLocalPeer,
)
2026-10-11 17:32:12 +08:00
import Control.Concurrent (forkIO, forkFinally, killThread, threadDelay)
2026-10-05 22:22:23 +08:00
import Control.Concurrent.MVar (MVar, modifyMVar_, newEmptyMVar, newMVar, putMVar, takeMVar)
import System.Timeout (timeout)
import Control.Exception (IOException, bracket, finally, try)
2026-10-15 22:21:41 +08:00
import Control.Monad (replicateM_, void, (>=>), when)
2026-10-05 22:22:23 +08:00
import Data.List (sort)
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.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
2026-10-11 17:32:12 +08:00
import Data.Time.Clock (addUTCTime, diffUTCTime, getCurrentTime)
import Network.Socket (
AddrInfo (..),
2026-10-05 22:22:23 +08:00
SockAddr (..),
tupleToHostAddress,
tupleToHostAddress6,
SocketType (Stream),
addrAddress,
addrFamily,
connect,
defaultHints,
defaultProtocol,
getAddrInfo,
socket,
socketToHandle,
withSocketsDo,
)
2026-10-03 16:49:23 +08:00
import System.Directory (createDirectoryIfMissing, doesFileExist, getTemporaryDirectory, removeFile, removePathForcibly)
import System.FilePath ((</>))
import System.IO (
BufferMode (LineBuffering),
Handle,
IOMode (ReadWriteMode),
hClose,
hFlush,
hIsEOF,
hSetBuffering,
hSetNewlineMode,
noNewlineTranslation,
)
import Test.Hspec hiding (after, before)
-- TCP 服务器测试:协议编解码、配置读取与真连服务器的端到端用例。
-- | 测试入口
main :: IO ()
main = hspec spec
-- | 全部用例
spec :: Spec
spec = do
2026-10-11 17:32:12 +08:00
normalizationSpec
2026-10-05 22:22:23 +08:00
describe "sudo authentication" $ do
it "defaults to disabled and rejects unconfigured TCP authentication" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \client -> do
scSudoUser defaultServerConfig `shouldBe` ""
challenge <- tcpTalk client "{\"method\":\"sudo_challenge\"}"
at "code" challenge `shouldBe` A.String "forbidden"
denied <- tcpTalk client "{\"method\":\"sudo_login\",\"user\":\"admin\",\"proof\":\"fake\"}"
at "code" denied `shouldBe` A.String "unauthorized"
it "loads an explicit normalized local identity mapping" $ do
path <- tempSettingsPath "sudo-config"
writeFile path (unlines ["[server]", "sudo_auth_user = " ++ show ("Local_User" :: String)])
config <- loadServerConfigAt path `finally` removeIfExists path
scSudoUser config `shouldBe` "local_user"
it "supports the client challenge and login exchange" $
withTestSudo $ \_ handle credential -> do
connected <- connectClient "127.0.0.1" (shPort handle)
case connected of
Left message -> expectationFailure (T.unpack message)
Right client -> (do
clientSudoLogin client credential "local_user" >>= (`shouldBe` Right False)
result <- clientQuery client "select 1"
result `shouldSatisfy` isRightResult) `finally` closeClient client
it "only treats kernel loopback addresses as local" $ do
isLocalPeer (SockAddrInet 7777 (tupleToHostAddress (127, 0, 0, 1))) `shouldBe` True
isLocalPeer (SockAddrInet 7777 (tupleToHostAddress (192, 168, 1, 2))) `shouldBe` False
isLocalPeer (SockAddrInet6 7777 0 (tupleToHostAddress6 (0,0,0,0,0,0,0,1)) 0) `shouldBe` True
isLocalPeer (SockAddrInet6 7777 0 (tupleToHostAddress6 (0,0,0,0,0,65535,32512,1)) 0) `shouldBe` True
isLocalPeer (SockAddrInet6 7777 0 (tupleToHostAddress6 (0,0,0,0,0,65535,49320,1)) 0) `shouldBe` False
it "binds proofs to the instance, challenge, account and expiration" $ do
credential <- newSudoCredential
other <- newSudoCredential
now <- getCurrentTime
let proof = sudoProof credential "nonce" "local_user"
verifySudoProof credential "nonce" "LOCAL_USER" proof `shouldBe` True
verifySudoProof other "nonce" "local_user" proof `shouldBe` False
verifySudoProof credential "other" "local_user" proof `shouldBe` False
verifySudoProof credential "nonce" "admin" proof `shouldBe` False
validSudoChallenge credential now (Just ("nonce", addUTCTime (-61) now)) "local_user" proof `shouldBe` False
validSudoChallenge credential now (Just ("nonce", addUTCTime 1 now)) "local_user" proof `shouldBe` False
validSudoChallenge credential now Nothing "local_user" proof `shouldBe` False
it "requires an OS elevated identity to provision or read credentials" $ do
privileged <- sudoPrivileged
if privileged then do
bracket (createSudoCredential 65534) (const (removeSudoCredential 65534)) $ \credential -> do
stored <- readSudoCredential 65534
case stored of
Left message -> expectationFailure (T.unpack message)
Right restored -> sudoProof restored "native" "local_user" `shouldBe` sudoProof credential "native" "local_user"
duplicate <- try (createSudoCredential 65534) :: IO (Either IOException SudoCredential)
duplicate `shouldSatisfy` isLeftResult
else do
created <- try (createSudoCredential 65534) :: IO (Either IOException SudoCredential)
created `shouldSatisfy` isLeftResult
readSudoCredential 65534 >>= (`shouldSatisfy` isLeftResult)
it "logs in an ordinary mapped identity without elevating its privileges" $
withTestSudo $ \_ handle credential -> withConnection handle $ \client -> do
answer <- tcpSudo client credential "local_user"
at "status" answer `shouldBe` A.String "ok"
at "admin" answer `shouldBe` A.Bool False
denied <- tcpQuery client "create database forbidden"
at "code" denied `shouldBe` A.String "forbidden"
it "rejects a forged proof and consumes its challenge" $
withTestSudo $ \_ handle credential -> withConnection handle $ \client -> do
nonce <- tcpTalk client "{\"method\":\"sudo_challenge\"}"
denied <- tcpTalk client "{\"method\":\"sudo_login\",\"user\":\"local_user\",\"proof\":\"forged\"}"
at "code" denied `shouldBe` A.String "unauthorized"
replay <- tcpSudoProof client "local_user" (sudoProof credential (asText (at "challenge" nonce)) "local_user")
at "code" replay `shouldBe` A.String "unauthorized"
it "rejects proof reuse on another connection and account mapping" $
withTestSudo $ \_ handle credential -> withConnection handle $ \first -> withConnection handle $ \second -> do
nonce <- tcpTalk first "{\"method\":\"sudo_challenge\"}"
let proof = sudoProof credential (asText (at "challenge" nonce)) "local_user"
replay <- tcpSudoProof second "local_user" proof
at "code" replay `shouldBe` A.String "unauthorized"
mismatch <- tcpSudo first credential "admin"
at "code" mismatch `shouldBe` A.String "unauthorized"
it "revokes the attribute and drops existing sudo sessions" $
withTestSudo $ \_ handle credential -> withConnection handle $ \admin -> withConnection handle $ \client -> do
_ <- tcpLogin admin testRootName testRootPassword
_ <- tcpSudo client credential "local_user"
changed <- tcpQuery admin "alter user local_user noallow_sudo_auth"
at "status" changed `shouldBe` A.String "result"
expired <- tcpQuery client "select 1"
at "code" expired `shouldBe` A.String "unauthorized"
tcpClosed client
withConnection handle $ \fresh -> do
denied <- tcpSudo fresh credential "local_user"
at "code" denied `shouldBe` A.String "unauthorized"
it "rejects disabled and NOLOGIN identities" $
withTestSudo $ \_ handle credential -> withConnection handle $ \admin -> do
_ <- tcpLogin admin testRootName testRootPassword
mapM_ (checkSudoDisabled admin handle credential)
["alter user local_user disabled", "alter user local_user enabled nologin"]
it "does not inherit sudo authentication from a role" $
withTestSudo $ \_ handle credential -> withConnection handle $ \admin -> do
_ <- tcpLogin admin testRootName testRootPassword
_ <- tcpQuery admin "create role sudo_role"
_ <- tcpQuery admin "alter role sudo_role allow_sudo_auth"
_ <- tcpQuery admin "grant sudo_role to local_user"
_ <- tcpQuery admin "alter user local_user noallow_sudo_auth"
withConnection handle $ \client -> do
denied <- tcpSudo client credential "local_user"
at "code" denied `shouldBe` A.String "unauthorized"
it "keeps password login independent of the sudo attribute" $
withTestSudo $ \_ handle _ -> withConnection handle $ \client -> do
denied <- tcpLogin client "local_user" ""
at "code" denied `shouldBe` A.String "unauthorized"
accepted <- tcpLogin client "local_user" "Local-Pass123!"
at "status" accepted `shouldBe` A.String "ok"
tcpSpec
tcpServerSpec
privilegeSpec
2026-10-05 22:22:23 +08:00
objectPrivilegeSpec
2026-10-03 16:49:23 +08:00
transactionSpec
ipcConcurrencySpec
identitySpec
-- | 身份属性与权限边界的端到端测试
identitySpec :: Spec
identitySpec = describe "unified identities" $ do
it "lets catalog managers manage ordinary identities without gaining superuser" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \manager -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user manager identified by 'Worker-Pass123!'"
changed <- tcpQuery admin "alter user manager system_catalog_manager"
asText (at "status" changed) `shouldBe` "result"
signed <- tcpLogin manager "manager" "Worker-Pass123!"
asBool (at "admin" signed) `shouldBe` False
created <- tcpQuery manager "create user managed identified by 'Worker-Pass123!'"
asText (at "status" created) `shouldBe` "result"
shown <- tcpQuery manager "show roles"
asText (at "status" shown) `shouldBe` "result"
switched <- tcpQuery manager "use system"
asText (at "status" switched) `shouldBe` "result"
2026-10-15 22:21:41 +08:00
mapM_ (tcpQuery manager
>=>
(\ reply -> asText (at "code" reply) `shouldBe` "forbidden"))
2026-10-05 22:22:23 +08:00
["alter user manager superuser", "alter user managed system_catalog_manager", "alter user managed allow_sudo_auth", "alter user admin identified by 'Another-Pass123!'", "create database forbidden"]
it "does not inherit catalog management through role membership" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \worker -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user worker identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role catalog_admin"
_ <- tcpQuery admin "alter role catalog_admin system_catalog_manager"
_ <- tcpQuery admin "grant catalog_admin to worker"
_ <- tcpLogin worker "worker" "Worker-Pass123!"
denied <- tcpQuery worker "create user elevated identified by 'Worker-Pass123!'"
asText (at "code" denied) `shouldBe` "forbidden"
it "shares names and shows safe identity attributes" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
_ <- tcpQuery h "create role reader"
conflict <- tcpQuery h "create user reader identified by 'Worker-Pass123!'"
asText (at "code" conflict) `shouldBe` "conflict"
_ <- tcpQuery h "create user alice identified by 'Worker-Pass123!'"
other <- tcpQuery h "create role alice"
asText (at "code" other) `shouldBe` "conflict"
shown <- tcpQuery h "show roles"
2026-10-05 22:22:23 +08:00
map asText (items (at "columns" shown)) `shouldBe` ["id", "name", "can_login", "is_superuser", "enabled", "system_catalog_manager", "allow_sudo_auth", "created_at"]
asInt (at "rowCount" shown) `shouldBe` 3
reader <- tcpQuery h "alter role reader login nologin"
asText (at "code" reader) `shouldBe` "bad_request"
it "turns a role into a login identity and promotes by attribute" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \worker -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create role worker"
_ <- tcpQuery admin "alter role worker identified by 'Worker-Pass123!'"
denied <- tcpLogin worker "worker" "Worker-Pass123!"
asText (at "status" denied) `shouldBe` "error"
changed <- tcpQuery admin "alter role worker login superuser"
asText (at "status" changed) `shouldBe` "result"
allowed <- tcpLogin worker "worker" "Worker-Pass123!"
asBool (at "admin" allowed) `shouldBe` True
created <- tcpQuery worker "create role managed"
asText (at "status" created) `shouldBe` "result"
it "does not inherit the superuser attribute through membership" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role powerful"
_ <- tcpQuery admin "alter role powerful superuser"
_ <- tcpQuery admin "grant powerful to alice"
signed <- tcpLogin alice "alice" "Worker-Pass123!"
asBool (at "admin" signed) `shouldBe` False
denied <- tcpQuery alice "create role elevated"
asText (at "code" denied) `shouldBe` "forbidden"
it "treats a demoted configured administrator as ordinary" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user owner identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "alter user owner superuser"
demoted <- tcpQuery admin "alter user admin nosuperuser"
asText (at "status" demoted) `shouldBe` "result"
withConnection handle $ \fresh -> do
signed <- tcpLogin fresh "admin" "s3cret"
asBool (at "admin" signed) `shouldBe` False
denied <- tcpQuery fresh "show roles"
asText (at "code" denied) `shouldBe` "forbidden"
it "protects the last enabled login superuser" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
2026-10-15 22:21:41 +08:00
mapM_ (tcpQuery h
>=>
(\ reply -> asText (at "code" reply) `shouldBe` "bad_request"))
["alter user admin disabled", "alter role admin nologin", "alter user admin nosuperuser", "drop user admin", "drop role admin"]
shown <- tcpQuery h "show roles"
asText (at "status" shown) `shouldBe` "result"
it "stops disabled role inheritance and disconnects its members" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "use test"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role reader"
_ <- tcpQuery admin "grant select on users to reader"
_ <- tcpQuery admin "grant reader to alice"
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery alice "use test"
before <- tcpQuery alice "select * from users"
asText (at "status" before) `shouldBe` "result"
changed <- tcpQuery admin "alter role reader disabled"
asText (at "status" changed) `shouldBe` "result"
refused <- tcpQuery alice "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
tcpClosed alice
withConnection handle $ \fresh -> do
_ <- tcpLogin fresh "alice" "Worker-Pass123!"
_ <- tcpQuery fresh "use test"
after <- tcpQuery fresh "select * from users"
asText (at "code" after) `shouldBe` "forbidden"
escalation <- tcpQuery fresh "alter user alice superuser"
asText (at "code" escalation) `shouldBe` "forbidden"
it "disables login identities and invalidates their connections" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery admin "alter user alice disabled"
refused <- tcpQuery alice "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
tcpClosed alice
withConnection handle $ \fresh -> do
denied <- tcpLogin fresh "alice" "Worker-Pass123!"
asText (at "status" denied) `shouldBe` "error"
_ <- tcpQuery admin "alter user alice enabled"
allowed <- tcpLogin fresh "alice" "Worker-Pass123!"
asText (at "status" allowed) `shouldBe` "ok"
it "does not reuse dropped identity ids or permissions" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "use test"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "grant select on users to alice"
before <- tcpQuery admin "show roles"
_ <- tcpQuery admin "drop user alice"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
after <- tcpQuery admin "show roles"
at "rows" after `shouldNotBe` at "rows" before
withConnection handle $ \alice -> do
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery alice "use test"
denied <- tcpQuery alice "select * from users"
asText (at "code" denied) `shouldBe` "forbidden"
-- 夹具:内存后端装在 MVar 里,跟 Web 测试同一套表
-- | 夹具库名
testDatabaseName :: String
testDatabaseName = "test"
-- | 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]
]
2026-10-11 17:32:12 +08:00
Nothing Nothing Nothing
)
, ( "orders"
, Table
"orders"
[("id", TInt), ("user_id", TInt)]
[[("id", VInt 1), ("user_id", VInt 1)]]
2026-10-11 17:32:12 +08:00
Nothing Nothing Nothing
)
]
-- | 测试管理员名字
testRootName :: Text
testRootName = "admin"
-- | 测试管理员口令
testRootPassword :: Text
testRootPassword = "s3cret"
-- | 口令哈希:迭代次数压低,跑得快
testPasswordHash :: Text -> Text
2026-10-15 22:21:41 +08:00
testPasswordHash = hashPasswordWith 1000 (BS.replicate 16 7)
-- 小工具
-- | 取字段
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
-- | JSON 当布尔看
asBool :: A.Value -> Bool
asBool (A.Bool b) = b
asBool _ = False
-- | 第 i 个元素(越界给 Nothing,不用 `!!`)
nth :: Int -> [a] -> Maybe a
nth i xs = case drop i xs of
(x : _) -> Just x
[] -> Nothing
-- | 临时 TOML
tcpConfigPath :: String -> IO FilePath
tcpConfigPath name = do
tmp <- getTemporaryDirectory
pure (tmp </> ("chusql-server-test-" ++ name ++ ".toml"))
-- | 临时设置文件(新会话读口令策略用)
tempSettingsPath :: String -> IO FilePath
tempSettingsPath name = do
tmp <- getTemporaryDirectory
pure (tmp </> ("chusql-server-test-" ++ name ++ "-settings.json"))
-- | 文件不存在也算成功
removeIfExists :: FilePath -> IO ()
removeIfExists path = do
there <- doesFileExist path
2026-10-15 22:21:41 +08:00
when there
$ void (try (removeFile path) :: IO (Either IOException ()))
-- | 起一台内存后端的测试服务器:管理员已设口令 `s3cret`
withTcpServer :: ServerConfig -> (ServerEnv -> ServerHandle -> IO a) -> IO a
withTcpServer = withTcpServerAs (Just testRootPassword)
-- | 起内存后端测试服务器;Nothing 表示首启未设口令
withTcpServerAs :: Maybe Text -> ServerConfig -> (ServerEnv -> ServerHandle -> IO a) -> IO a
withTcpServerAs password config body = do
db <- newMVar testDb
limiter <- newRateLimiter getCurrentTime 50 300
path <- tempSettingsPath "tcp-server"
removeIfExists path
let backend = memoryBackend testDatabaseName db
_ <- ensureRootAccount backend testRootName
case password of
Nothing -> pure ()
Just raw -> void (beAccounts backend (ReqAccountReset testRootName (testPasswordHash raw)))
env <- newServerEnv backend testRootName path config{scPort = 0} limiter
handle <- startServer env
body env handle `finally` shStop handle
-- | 连上服务器并配成行缓冲句柄
withConnection :: ServerHandle -> (Handle -> IO a) -> IO a
2026-10-15 22:21:41 +08:00
withConnection handle = bracket open closeQuietly
where
-- | 关连接,忽略异常
closeQuietly h = do
_ <- try (hClose h) :: IO (Either IOException ())
pure ()
-- | 建连接并配成行缓冲句柄
open = withSocketsDo $ do
infos <- getAddrInfo (Just defaultHints{addrSocketType = Stream}) (Just "127.0.0.1") (Just (show (shPort handle)))
info <- case infos of
(i : _) -> pure i
[] -> ioError (userError "cannot resolve the test server address")
sock <- socket (addrFamily info) Stream defaultProtocol
connect sock (addrAddress info)
h <- socketToHandle sock ReadWriteMode
hSetBuffering h LineBuffering
hSetNewlineMode h noNewlineTranslation
pure h
-- | 发一行收一行;服务器不回话就判失败,别把用例挂死
tcpTalk :: Handle -> BS.ByteString -> IO A.Value
tcpTalk h raw = do
BSC.hPutStr h raw
BSC.hPutStr h "\n"
hFlush h
line <- timeout (5 * 1000000) (BSC.hGetLine h)
case line of
Just got -> pure (fromMaybe A.Null (decode (BL.fromStrict got)))
Nothing -> do
expectationFailure "the TCP server did not answer in time"
pure A.Null
-- | 登录一行
tcpLogin :: Handle -> Text -> Text -> IO A.Value
tcpLogin h user password =
tcpTalk h (BSC.pack ("{\"method\":\"login\",\"user\":\"" ++ T.unpack user ++ "\",\"password\":\"" ++ T.unpack password ++ "\"}"))
-- | 查询一行
tcpQuery :: Handle -> Text -> IO A.Value
tcpQuery h sql = tcpTalk h (BSC.pack ("{\"method\":\"query\",\"sql\":\"" ++ T.unpack sql ++ "\"}"))
-- | 断言对端已经收工(读到 EOF)
tcpClosed :: Handle -> IO ()
tcpClosed h = do
done <- timeout (5 * 1000000) (hIsEOF h)
done `shouldBe` Just True
-- 用例
-- | 协议与配置的用例
tcpSpec :: Spec
tcpSpec = do
describe "TCP protocol: decoding a request line" $ do
it "hello without a protocol number takes the current version" $
decodeRequest "{\"method\":\"hello\"}" `shouldBe` Right (ReqHello 1)
it "hello keeps the protocol number the client asked for" $
decodeRequest "{\"method\":\"hello\",\"protocol\":2}" `shouldBe` Right (ReqHello 2)
it "method names are case-insensitive" $
decodeRequest "{\"method\":\"PING\"}" `shouldBe` Right ReqPing
it "unknown extra fields are ignored" $
decodeRequest "{\"method\":\"quit\",\"line\":7}" `shouldBe` Right ReqQuit
it "login without a password is refused" $
decodeRequest "{\"method\":\"login\",\"user\":\"admin\"}" `shouldBe` Left "login needs both user and password"
it "query without sql is refused" $
decodeRequest "{\"method\":\"query\"}" `shouldBe` Left "query needs sql"
it "an unknown method is named in the error" $
decodeRequest "{\"method\":\"frobnicate\"}" `shouldBe` Left "unknown method: frobnicate"
it "a line that is not JSON is a bad request, not a crash" $
decodeRequest "not json" `shouldBe` Left "request is not valid JSON"
describe "TCP protocol: encoding a response" $ do
it "hello carries the protocol version and the server name" $ do
let value = fromMaybe A.Null (decode (encodeResponse (RespHello 1)))
asText (at "status" value) `shouldBe` "hello"
asText (at "server" value) `shouldBe` "chusql-server"
asInt (at "protocol" value) `shouldBe` 1
it "login reports the account and whether it is the administrator" $ do
let value = fromMaybe A.Null (decode (encodeResponse (RespLogin "admin" True)))
asText (at "user" value) `shouldBe` "admin"
asBool (at "admin" value) `shouldBe` True
it "an error carries a code and a message" $ do
let value = fromMaybe A.Null (decode (encodeResponse (RespError "no_database" "no database selected")))
asText (at "status" value) `shouldBe` "error"
asText (at "code" value) `shouldBe` "no_database"
asText (at "message" value) `shouldBe` "no database selected"
it "a result set is the command line JSON with a status field added" $ do
let sample = QueryResult ["id"] [[VInt 1]] 1 False (Just "test")
value = fromMaybe A.Null (decode (encodeResponse (RespResult sample)))
asText (at "status" value) `shouldBe` "result"
map asText (items (at "columns" value)) `shouldBe` ["id"]
asInt (at "rowCount" value) `shouldBe` 1
asBool (at "truncated" value) `shouldBe` False
asText (at "database" value) `shouldBe` "test"
2026-10-03 21:03:17 +08:00
describe "memory backend: catalog statistics" $ do
it "reports a histogram for a numeric column" $ do
db <- newMVar testDb
let backend = memoryBackend testDatabaseName db
infos <- beCatalog backend
case infos of
Left err -> expectationFailure err
Right tables -> case [i | i <- tables, tiTable i == "users"] of
[] -> expectationFailure "the catalog is missing the users table"
(info : _) -> do
fmap histLow (lookup "id" (tiHistograms info)) `shouldBe` Just 1
fmap histHigh (lookup "id" (tiHistograms info)) `shouldBe` Just 5
fmap (sum . histBuckets) (lookup "id" (tiHistograms info)) `shouldBe` Just 5
lookup "name" (tiHistograms info) `shouldBe` Nothing
describe "server config ([server] in settings.toml)" $ do
it "falls back to the defaults when the file has no server section" $ do
path <- tcpConfigPath "server-default"
removeIfExists path
loadServerConfigAt path `shouldReturn` defaultServerConfig
it "reads listen_host, port, max_message and max_rows" $ do
path <- tcpConfigPath "server-keys"
BS.writeFile path "[server]\nlisten_host = \"0.0.0.0\"\nport = 7777\nmax_message = 4096\nmax_rows = 7\n"
config <- loadServerConfigAt path
scHost config `shouldBe` "0.0.0.0"
scPort config `shouldBe` 7777
scMaxMessage config `shouldBe` 4096
scMaxRows config `shouldBe` 7
it "keeps the HTTP, storage and page keys out of the server config" $ do
path <- tcpConfigPath "server-other-keys"
BS.writeFile path "[web]\nport = 8888\n\n[storage]\ndata_dir = \"/tmp/chusql\"\n\n[page]\nsize = 8192\n"
config <- loadServerConfigAt path
scPort config `shouldBe` scPort defaultServerConfig
scMaxRows config `shouldBe` scMaxRows defaultServerConfig
-- | 真连 TCP 服务器的用例
tcpServerSpec :: Spec
tcpServerSpec = do
describe "TCP server: handshake and login" $ do
it "binds a real port when asked for port 0" $
withTcpServer defaultServerConfig $ \_ handle ->
shPort handle `shouldSatisfy` (> 0)
it "answers hello with the same protocol version" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":1}"
asText (at "status" reply) `shouldBe` "hello"
asInt (at "protocol" reply) `shouldBe` 1
it "still answers a client that connects long after startup" $
-- 回归点:早先 accept 外面套 timeout 轮询,超时异常会在 accept
-- 返回后把刚拿到的连接丢掉,只有延迟连接的客户端能撞上
withTcpServer defaultServerConfig $ \_ handle -> do
threadDelay 400000
withConnection handle $ \h -> do
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":1}"
asText (at "status" reply) `shouldBe` "hello"
asText (at "server" reply) `shouldBe` "chusql-server"
it "refuses a protocol version it does not speak" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":9}"
asText (at "code" reply) `shouldBe` "bad_request"
asText (at "message" reply) `shouldBe` "unsupported protocol version: 9"
it "refuses a query before login" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
reply <- tcpQuery h "select * from users"
asText (at "status" reply) `shouldBe` "error"
asText (at "code" reply) `shouldBe` "unauthorized"
asText (at "message" reply) `shouldBe` "sign in first"
it "signs the administrator in with an empty password until one is set" $
withTcpServerAs Nothing defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
wrong <- tcpLogin h "admin" "nope"
asText (at "code" wrong) `shouldBe` "unauthorized"
good <- tcpLogin h "admin" ""
asText (at "status" good) `shouldBe` "ok"
asBool (at "admin" good) `shouldBe` True
it "refuses a wrong password and then accepts the right one" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
bad <- tcpLogin h "admin" "wrong"
asText (at "status" bad) `shouldBe` "error"
asText (at "code" bad) `shouldBe` "unauthorized"
good <- tcpLogin h "admin" "s3cret"
asText (at "status" good) `shouldBe` "ok"
asText (at "user" good) `shouldBe` "admin"
asBool (at "admin" good) `shouldBe` True
describe "TCP server: SQL over the wire" $ do
it "USE picks a database and the result reports it" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
reply <- tcpQuery h "use test"
asText (at "status" reply) `shouldBe` "result"
asText (at "database" reply) `shouldBe` "test"
it "SELECT returns the fixture rows" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
_ <- tcpQuery h "use test"
reply <- tcpQuery h "select * from users"
asText (at "status" reply) `shouldBe` "result"
map asText (items (at "columns" reply)) `shouldBe` ["id", "name", "age"]
asInt (at "rowCount" reply) `shouldBe` 5
let first = case map items (items (at "rows" reply)) of
(r : _) -> r
[] -> []
length first `shouldBe` 3
asInt (fromMaybe A.Null (nth 0 first)) `shouldBe` 1
asText (fromMaybe A.Null (nth 1 first)) `shouldBe` "user1"
it "an unknown table maps to not_found" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
_ <- tcpQuery h "use test"
reply <- tcpQuery h "select * from nope"
asText (at "code" reply) `shouldBe` "not_found"
it "a query without a selected database maps to no_database" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
reply <- tcpQuery h "select * from users"
asText (at "code" reply) `shouldBe` "no_database"
it "an empty sql is a bad request" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
reply <- tcpTalk h "{\"method\":\"query\",\"sql\":\" \"}"
asText (at "code" reply) `shouldBe` "bad_request"
asText (at "message" reply) `shouldBe` "sql must not be empty"
it "the row limit truncates the rows but keeps the total count" $
withTcpServer defaultServerConfig{scMaxRows = 2} $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
_ <- tcpQuery h "use test"
reply <- tcpQuery h "select * from users"
length (items (at "rows" reply)) `shouldBe` 2
asInt (at "rowCount" reply) `shouldBe` 5
asBool (at "truncated" reply) `shouldBe` True
describe "TCP server: framing and lifecycle" $ do
it "a bad JSON line does not kill the connection" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
bad <- tcpTalk h "not json"
asText (at "code" bad) `shouldBe` "bad_request"
pong <- tcpTalk h "{\"method\":\"ping\"}"
asText (at "status" pong) `shouldBe` "pong"
it "a line over the message limit is refused as too_large" $
withTcpServer defaultServerConfig{scMaxMessage = 32} $ \_ handle ->
withConnection handle $ \h -> do
reply <- tcpTalk h (BSC.pack ("{\"method\":\"query\",\"sql\":\"" ++ replicate 40 'a' ++ "\"}"))
asText (at "code" reply) `shouldBe` "too_large"
2026-10-09 09:27:44 +08:00
it "refuses oversized input before a newline and closes the connection" $
withTcpServer defaultServerConfig{scMaxMessage = 32} $ \_ handle ->
withConnection handle $ \h -> do
BSC.hPutStr h (BSC.replicate 33 'a')
hFlush h
line <- timeout 1000000 (BSC.hGetLine h)
case line of
Nothing -> expectationFailure "oversized input waited for a newline"
Just raw -> asText (at "code" (fromMaybe A.Null (decode (BL.fromStrict raw)))) `shouldBe` "too_large"
tcpClosed h
it "accepts exact-limit CRLF frames and preserves pipelined requests" $
withTcpServer defaultServerConfig{scMaxMessage = 17} $ \_ handle ->
withConnection handle $ \h -> do
BSC.hPutStr h "{\"method\":\"ping\"}\r\n{\"method\":\"ping\"}\n"
hFlush h
replicateM_ 2 $ do
raw <- timeout 1000000 (BSC.hGetLine h)
fmap (asText . at "status" . fromMaybe A.Null . decode . BL.fromStrict) raw `shouldBe` Just "pong"
it "ping answers pong and quit closes the connection" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
pong <- tcpTalk h "{\"method\":\"ping\"}"
asText (at "status" pong) `shouldBe` "pong"
bye <- tcpTalk h "{\"method\":\"quit\"}"
asText (at "status" bye) `shouldBe` "bye"
tcpClosed h
it "one connection is one session" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
reply <- tcpLogin h "admin" "s3cret"
asText (at "status" reply) `shouldBe` "ok"
withConnection handle $ \other -> do
blocked <- tcpQuery other "select * from users"
asText (at "code" blocked) `shouldBe` "unauthorized"
2026-10-03 22:08:40 +08:00
describe "TCP server: account changes drop live sessions" $ do
it "disconnects a member whose role was revoked" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user bob identified by 'bob-password-1'"
_ <- tcpQuery admin "create role reader"
_ <- tcpQuery admin "use test"
_ <- tcpQuery admin "grant select on users to reader"
_ <- tcpQuery admin "grant reader to bob"
withConnection handle $ \bob -> do
_ <- tcpLogin bob "bob" "bob-password-1"
_ <- tcpQuery bob "use test"
allowed <- tcpQuery bob "select name from users"
asText (at "status" allowed) `shouldBe` "result"
_ <- tcpQuery admin "revoke reader from bob"
2026-10-04 14:44:40 +08:00
refused <- tcpQuery bob "select name from users"
asText (at "code" refused) `shouldBe` "unauthorized"
2026-10-03 22:08:40 +08:00
tcpClosed bob
it "disconnects every member of a role that was dropped" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user bob identified by 'bob-password-1'"
_ <- tcpQuery admin "create role reader"
_ <- tcpQuery admin "grant reader to bob"
withConnection handle $ \bob -> do
_ <- tcpLogin bob "bob" "bob-password-1"
_ <- tcpQuery admin "drop role reader"
2026-10-04 14:44:40 +08:00
refused <- tcpQuery bob "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
2026-10-03 22:08:40 +08:00
tcpClosed bob
it "disconnects an account whose password was reset" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user bob identified by 'bob-password-1'"
withConnection handle $ \bob -> do
_ <- tcpLogin bob "bob" "bob-password-1"
_ <- tcpQuery admin "alter user bob identified by 'bob-password-2'"
2026-10-04 14:44:40 +08:00
refused <- tcpQuery bob "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
2026-10-03 22:08:40 +08:00
tcpClosed bob
withConnection handle $ \again -> do
relogin <- tcpLogin again "bob" "bob-password-2"
asText (at "status" relogin) `shouldBe` "ok"
it "disconnects an account that was dropped" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user bob identified by 'bob-password-1'"
withConnection handle $ \bob -> do
_ <- tcpLogin bob "bob" "bob-password-1"
_ <- tcpQuery admin "drop user bob"
2026-10-04 14:44:40 +08:00
refused <- tcpQuery bob "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
2026-10-03 22:08:40 +08:00
tcpClosed bob
it "leaves the connections of untouched accounts alone" $
withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user bob identified by 'bob-password-1'"
withConnection handle $ \bob -> do
_ <- tcpLogin bob "bob" "bob-password-1"
_ <- tcpQuery admin "create user carol identified by 'carol-password-1'"
_ <- tcpQuery admin "alter user carol identified by 'carol-password-2'"
still <- tcpQuery bob "use test"
asText (at "status" still) `shouldBe` "result"
-- 权限服务夹具:内存后端上的系统库视角
-- | 一个普通账号
testAccount :: Account
2026-10-05 22:22:23 +08:00
testAccount = Account 0 "alice" "" 0 "" Nothing True False True False False
-- | 权限用例传的当前库名
testDatabase :: Text
testDatabase = T.pack testDatabaseName
-- | 一个空的权限服务加管理员身份
withPrivileges :: (Privileges -> Principal -> IO a) -> IO a
withPrivileges body = do
db <- newMVar testDb
2026-10-05 22:22:23 +08:00
let backend = memoryBackend testDatabaseName db
_ <- beAccounts backend (ReqAccountCreate "alice" "hash")
_ <- beAccounts backend (ReqAccountCreate "bob" "hash")
service <- newPrivileges backend
body service (Root testRootName)
2026-10-03 19:21:02 +08:00
-- | 解析一条 SQL,失败就丢掉用例
parseOrFail :: String -> IO Statement
parseOrFail sql = case parseStatement sql of
Left err -> expectationFailure err >> fail "statement did not parse"
Right stmt -> pure stmt
-- | 用某个身份鉴权一条 SQL
authorizeSql :: Privileges -> Principal -> String -> IO (Either PrivilegeError ())
authorizeSql service principal sql = parseOrFail sql >>= authorize service principal testDatabase
-- | 权限与目录 API 用例
privilegeSpec :: Spec
privilegeSpec = describe "server privileges" $ do
it "creates a role and lists it" $ withPrivileges $ \service root -> do
created <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
created `shouldBe` Right ()
views <- listRoleViews service
fmap (map roleName) views `shouldBe` Right ["reader"]
it "refuses a role that already exists" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
again <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
again `shouldBe` Left (PrivilegeError "conflict" "role already exists: reader")
it "grants a privilege on a table and shows it in the role view" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
2026-10-03 19:35:30 +08:00
granted <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
granted `shouldBe` Right ()
views <- listRoleViews service
2026-10-03 19:35:30 +08:00
fmap (map roleGrants) views `shouldBe` Right [[Grant "reader" "select" "test.users" False]]
it "expands ALL and keeps a star object as is" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["all"] "*" "reader" False)
views <- listRoleViews service
2026-10-15 22:21:41 +08:00
fmap (concatMap (map grantPrivilege . roleGrants)) views
`shouldBe` Right ["select", "insert", "update", "delete"]
2026-10-15 22:21:41 +08:00
fmap (concatMap (map grantObject . roleGrants)) views `shouldBe` Right (replicate 4 "test.*")
it "revokes a privilege again" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "reader")
views <- listRoleViews service
fmap (map roleGrants) views `shouldBe` Right [[]]
2026-10-03 19:35:30 +08:00
it "marks a grant that carries the grant option" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" True)
views <- listRoleViews service
fmap (map roleGrants) views `shouldBe` Right [[Grant "reader" "select" "test.users" True]]
it "refuses a non-administrator without the grant option" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
refused <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "writer" False)
refused `shouldBe` Left (PrivilegeError "forbidden" "grant option required: SELECT ON users")
it "lets a grant option holder pass the privilege on" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" True)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
passed <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "writer" False)
passed `shouldBe` Right ()
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "writer" ["bob"])
views <- listRoleViews service
fmap (map roleGrants) views `shouldBe` Right [[Grant "reader" "select" "test.users" True], [Grant "writer" "select" "test.users" False]]
it "stops the pass-on once the grant option is revoked" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" True)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
_ <- runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "reader")
refused <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "writer" False)
refused `shouldBe` Left (PrivilegeError "forbidden" "grant option required: SELECT ON users")
views <- listRoleViews service
fmap (map roleGrants) views `shouldBe` Right [[], []]
it "adds and removes members" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice", "bob"])
views <- listRoleViews service
fmap (map roleMembers) views `shouldBe` Right [["alice", "bob"]]
_ <- runPrivilegeCommand service root testDatabase (RevokeRoleCommand "reader" ["alice"])
remaining <- listRoleViews service
fmap (map roleMembers) remaining `shouldBe` Right [["bob"]]
it "drops a role together with its grants and members" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
dropped <- runPrivilegeCommand service root testDatabase (DropRoleCommand "reader")
dropped `shouldBe` Right ()
views <- listRoleViews service
views `shouldBe` Right []
2026-10-03 19:21:02 +08:00
it "inherits the grants of another role" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
2026-10-03 19:21:02 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["writer"])
linked <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "writer" ["alice"])
linked `shouldBe` Right ()
-- 继承链上的授权跟着成员传上来
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
views <- listRoleViews service
case views of
Left err -> expectationFailure (show err)
Right known -> do
let membersOf name = concat [ms | view <- known, roleName view == name, ms <- [roleMembers view]]
membersOf "reader" `shouldMatchList` ["writer"]
membersOf "writer" `shouldMatchList` ["alice"]
revoked <- runPrivilegeCommand service root testDatabase (RevokeRoleCommand "reader" ["writer"])
revoked `shouldBe` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "refuses a role membership that would create a cycle" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["writer"])
loop <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "writer" ["reader"])
loop `shouldBe` Left (PrivilegeError "conflict" "role membership would create a cycle: writer and reader")
self <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["reader"])
self `shouldBe` Left (PrivilegeError "conflict" "role membership would create a cycle: reader and reader")
it "drops the inherited edge together with the role" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "writer")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
2026-10-03 19:21:02 +08:00
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["writer"])
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "writer" ["alice"])
dropped <- runPrivilegeCommand service root testDatabase (DropRoleCommand "reader")
dropped `shouldBe` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
views <- listRoleViews service
fmap (map roleMembers) views `shouldBe` Right [["alice"]]
it "refuses a command on a role that does not exist" $ withPrivileges $ \service root -> do
2026-10-03 19:35:30 +08:00
missing <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "ghost" False)
missing `shouldBe` Left (PrivilegeError "not_found" "unknown role: ghost")
it "refuses a table grant without a selected database" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
2026-10-03 19:35:30 +08:00
refused <- runPrivilegeCommand service root "" (GrantPrivilegesCommand ["select"] "users" "reader" False)
refused `shouldBe` Left (PrivilegeError "no_database" "no database selected")
it "refuses a non-administrator" $ withPrivileges $ \service _ -> do
refused <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (CreateRoleCommand "reader")
refused `shouldBe` Left (PrivilegeError "forbidden" "administrator required")
it "requires administrator privileges for domain commands" $ withPrivileges $ \service root -> do
authorizeSql service root "CREATE DOMAIN code AS INT" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "CREATE DOMAIN code AS INT"
`shouldReturn` Left (PrivilegeError "forbidden" "administrator required")
it "authorizes the administrator on any statement" $ withPrivileges $ \service root ->
case parseStatement "SELECT * FROM users" of
Left err -> expectationFailure err
Right stmt -> authorize service root testDatabase stmt `shouldReturn` Right ()
2026-10-03 19:49:57 +08:00
it "requires the privilege on the table inside a derived table" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
authorizeSql service (Ordinary testAccount) "SELECT d.name FROM (SELECT name FROM users) d"
`shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT d.name FROM (SELECT product FROM orders) d"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON orders")
2026-10-03 20:04:33 +08:00
it "requires the privilege on the table inside a CTE" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
authorizeSql service (Ordinary testAccount) "WITH d AS (SELECT name FROM users) SELECT d.name FROM d"
`shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "WITH d AS (SELECT product FROM orders) SELECT d.product FROM d"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON orders")
2026-10-03 20:23:18 +08:00
it "requires the privilege on the table inside a comparison subquery" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
authorizeSql service (Ordinary testAccount) "SELECT name FROM users WHERE id >= (SELECT id FROM users)"
`shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT name FROM users WHERE id >= (SELECT id FROM orders)"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON orders")
2026-10-03 20:52:02 +08:00
it "requires the privilege on the table inside a quantifier subquery" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
authorizeSql service (Ordinary testAccount) "SELECT name FROM users WHERE id > ANY (SELECT id FROM users)"
`shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT name FROM users WHERE id > ANY (SELECT id FROM orders)"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON orders")
2026-10-15 22:21:41 +08:00
it "requires SELECT on subqueries in HAVING and expression ordering" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
mapM_ (\sql -> authorizeSql service (Ordinary testAccount) sql
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON orders"))
[ "SELECT COUNT(*) FROM users HAVING COUNT(*) > (SELECT COUNT(*) FROM orders)"
, "SELECT name FROM users ORDER BY (SELECT COUNT(*) FROM orders) + id"
]
2026-10-03 22:08:40 +08:00
it "lists the accounts a role change touches, members included" $ withPrivileges $ \service root -> do
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service root testDatabase (CreateRoleCommand "inner")
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "inner" ["reader"])
_ <- runPrivilegeCommand service root testDatabase (GrantRoleCommand "reader" ["alice"])
directly <- affectedAccounts service (RevokeRoleCommand "inner" ["bob"])
2026-10-05 22:22:23 +08:00
fmap sort directly `shouldBe` Right ["bob"]
2026-10-04 14:44:40 +08:00
granted <- affectedAccounts service (GrantRoleCommand "writer" ["inner"])
2026-10-05 22:22:23 +08:00
fmap sort granted `shouldBe` Right (sort ["inner", "reader", "alice"])
2026-10-03 22:08:40 +08:00
dropped <- affectedAccounts service (DropRoleCommand "inner")
2026-10-05 22:22:23 +08:00
fmap sort dropped `shouldBe` Right (sort ["inner", "reader", "alice"])
affectedAccounts service (CreateRoleCommand "other") `shouldReturn` Right []
2026-10-03 22:08:40 +08:00
2026-10-03 17:16:26 +08:00
it "keeps the role tables on the real storage" $ withIpcStorage $ do
base <- ipcBackend
service <- newPrivileges base
created <- runPrivilegeCommand service (Root testRootName) testDatabase (CreateRoleCommand "reader")
created `shouldBe` Right ()
views <- listRoleViews service
fmap (map roleName) views `shouldBe` Right ["reader"]
stored <- beAccounts base ReqAccountsList
fmap (map (\a -> (accountUser a, accountCanLogin a))) stored `shouldBe` Right [("root", True), ("reader", False)]
2026-10-03 17:16:26 +08:00
let system = beWithDatabase base "system"
-- 账号表不给语句通道看到
denied <- beStatement system "SELECT * FROM __system_users"
case denied of
Left _ -> pure ()
Right result -> expectationFailure ("account table must stay hidden: " ++ show (srRows result))
2026-10-03 19:21:02 +08:00
it "inherits a role on the real storage" $ withIpcStorage $ do
base <- ipcBackend
2026-10-05 22:22:23 +08:00
seedPrivilegeObjects base
2026-10-03 19:21:02 +08:00
service <- newPrivileges base
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (CreateRoleCommand "writer")
2026-10-03 19:35:30 +08:00
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" False)
2026-10-03 19:21:02 +08:00
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (GrantRoleCommand "reader" ["writer"])
linked <- runPrivilegeCommand service (Root testRootName) testDatabase (GrantRoleCommand "writer" ["alice"])
linked `shouldBe` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
2026-10-03 19:35:30 +08:00
it "keeps a grant option on the real storage" $ withIpcStorage $ do
base <- ipcBackend
2026-10-05 22:22:23 +08:00
seedPrivilegeObjects base
2026-10-03 19:35:30 +08:00
service <- newPrivileges base
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (CreateRoleCommand "reader")
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (CreateRoleCommand "writer")
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (GrantPrivilegesCommand ["select"] "users" "reader" True)
_ <- runPrivilegeCommand service (Root testRootName) testDatabase (GrantRoleCommand "reader" ["alice"])
views <- listRoleViews service
fmap (map roleGrants) views `shouldBe` Right [[Grant "reader" "select" "test.users" True], []]
passed <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "writer" False)
passed `shouldBe` Right ()
after <- listRoleViews service
fmap (map roleGrants) after `shouldBe` Right [[Grant "reader" "select" "test.users" True], [Grant "writer" "select" "test.users" False]]
2026-10-03 16:49:23 +08:00
-- 真存储夹具:一个用例一份数据目录,跑完关链路删干净
2026-10-05 22:22:23 +08:00
-- 创建独立的对象授权内存夹具。
withObjectPrivileges :: (Backend -> Privileges -> Principal -> IO ()) -> IO ()
withObjectPrivileges body = do
db <- newMVar testDb
let backend = memoryBackend testDatabaseName db
mapM_ (\name -> beAccounts backend (ReqAccountCreate name "hash") >>= either (fail . show) (const (pure ()))) ["alice", "bob", "carol"]
service <- newPrivileges backend
body backend service (Root testRootName)
-- 验证对象作用域、授权来源和外部入口。
objectPrivilegeSpec :: Spec
objectPrivilegeSpec = describe "stable object privileges" $ do
2026-10-11 17:32:12 +08:00
it "lists only the caller's effective grants including roles and PUBLIC" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (CreateRoleCommand "self_reader") `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "self_reader" True) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantRoleCommand "self_reader" ["alice"]) `shouldReturn` Right ()
grants <- listSelfGrants service (Ordinary testAccount)
grants `shouldSatisfy` either (const False) (elem (Grant "alice" "select" "test.users" True))
grants `shouldSatisfy` either (const False) (elem (Grant "alice" "connect" "database:test" False))
grants `shouldSatisfy` either (const False) (all ((== "alice") . grantRole))
runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "self_reader") `shouldReturn` Right ()
after <- listSelfGrants service (Ordinary testAccount)
after `shouldSatisfy` either (const False) (not . any ((== "test.users") . grantObject))
_ <- beAccounts backend (ReqAccountReset "alice" (testPasswordHash "Alice-Secret-1234"))
_ <- beAccounts backend (ReqAccountCreate testRootName (testPasswordHash testRootPassword))
_ <- beAccounts backend (ReqIdentityAlter testRootName Nothing (Just True) Nothing Nothing Nothing)
settings <- tempSettingsPath "self-grants"
session <- newSession backend testRootName settings
authenticateSessionCoded session "alice" "Alice-Secret-1234" `shouldReturn` Right ()
result <- mustSql session "SHOW GRANTS"
qrColumns result `shouldBe` ["identity", "privilege", "object", "grant_option"]
qrRows result `shouldSatisfy` all (\row -> take 1 row == [VStr "alice"])
rejected <- runStatementCoded session "SHOW GRANTS FOR admin"
rejected `shouldSatisfy` either (const True) (const False)
it "includes ownership permissions and excludes ungranted tables" $ withObjectPrivileges $ \backend service _ -> do
mustRun (beStatement (beWithDatabase backend "test") "CREATE TABLE personal_owned (id INT)")
claimObject service (Ordinary testAccount) testDatabase "personal_owned" `shouldReturn` Right ()
grants <- listSelfGrants service (Ordinary testAccount)
fmap (filter ((== "test.personal_owned") . grantObject)) grants `shouldBe` Right
[Grant "alice" privilege "test.personal_owned" True | privilege <- ["select", "insert", "update", "delete"]]
grants `shouldSatisfy` either (const False) (not . any ((== "test.users") . grantObject))
it "checks DISTINCT table reads and nested subqueries" $ withObjectPrivileges $ \_ service root -> do
authorizeSql service (Ordinary testAccount) "SELECT DISTINCT age FROM users"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
authorizeSql service (Ordinary testAccount) "SELECT DISTINCT d.age FROM (SELECT DISTINCT age FROM users) d"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" False) `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT DISTINCT age FROM users" `shouldReturn` Right ()
2026-10-05 22:22:23 +08:00
it "migrates old table grants once and ignores later legacy writes" $ withObjectPrivileges $ \backend service _ -> do
let system = beWithDatabase backend "system"
mustRun (beStatement system "CREATE TABLE __system_grants (role VARCHAR(64), privilege VARCHAR(16), object VARCHAR(128))")
mustRun (beStatement system "INSERT INTO __system_grants (role, privilege, object) VALUES ('alice', 'select', 'test.users')")
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
mustRun (beStatement system "INSERT INTO __system_grants (role, privilege, object) VALUES ('bob', 'select', 'test.users')")
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "recovers a durable creator intent after table creation and cancels absent objects" $ withIpcStorage $ do
backend <- ipcBackend
seedPrivilegeObjects backend
service <- newPrivileges backend
prepareObject service (Ordinary testAccount) "test" "recovered_owned" `shouldReturn` Right ()
mustRun (beStatement (beWithDatabase backend "test") "CREATE TABLE recovered_owned (id INT)")
closeConnection
dir <- ipcDataDir
opened <- localStorageLink (Just (dir ++ ".toml"))
link <- either fail pure opened
setStorageLink link
fresh <- ipcBackend >>= newPrivileges
authorizeSql fresh (Ordinary testAccount) "SELECT * FROM recovered_owned" `shouldReturn` Right ()
prepareObject fresh (Ordinary testAccount) "test" "never_created" `shouldReturn` Right ()
authorizeSql fresh (Ordinary testAccount) "SELECT * FROM recovered_owned" `shouldReturn` Right ()
mustRun (beStatement (beWithDatabase backend "test") "CREATE TABLE never_created (id INT)")
authorizeSql fresh (Ordinary testAccount) "SELECT * FROM never_created" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON never_created")
it "holds authorization through a concurrent query and orders revocation after it" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" False) `shouldReturn` Right ()
_ <- beAccounts backend (ReqAccountCreate testRootName (testPasswordHash testRootPassword))
_ <- beAccounts backend (ReqIdentityAlter testRootName Nothing (Just True) Nothing Nothing Nothing)
_ <- beAccounts backend (ReqAccountReset "alice" (testPasswordHash "Alice-Secret-1234"))
started <- newEmptyMVar
release <- newEmptyMVar
finished <- newEmptyMVar
revoked <- newEmptyMVar
let slow = backend{beWithDatabase = \name -> (beWithDatabase backend name){beStatement = \sql -> do
2026-10-15 22:21:41 +08:00
when (sql == "SELECT * FROM users")
$ putMVar started () >> takeMVar release
2026-10-05 22:22:23 +08:00
beStatement (beWithDatabase backend name) sql}}
settings <- tempSettingsPath "permission-race"
reader <- newSession slow testRootName settings
admin <- newSession backend testRootName settings
authenticateSessionCoded reader "alice" "Alice-Secret-1234" `shouldReturn` Right ()
authenticateSessionCoded admin testRootName testRootPassword `shouldReturn` Right ()
_ <- mustSql reader "USE test"
_ <- mustSql admin "USE test"
_ <- forkIO (runStatementCoded reader "SELECT * FROM users" >>= putMVar finished)
takeMVar started
_ <- forkIO (runStatementCoded admin "REVOKE SELECT ON users FROM alice" >>= putMVar revoked)
blocked <- timeout 100000 (takeMVar revoked)
case blocked of Nothing -> pure (); Just _ -> expectationFailure "revocation raced past an authorized query"
putMVar release ()
takeMVar finished >>= either (expectationFailure . show) (const (pure ()))
takeMVar revoked >>= either (expectationFailure . show) (const (pure ()))
result <- runStatementCoded reader "SELECT * FROM users"
case result of Left err -> sessCode err `shouldBe` "forbidden"; Right _ -> expectationFailure "revoked privilege remained active"
it "requires existing objects and validates database privilege scope" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "missing" "alice" False)
`shouldReturn` Left (PrivilegeError "not_found" "unknown table")
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "database:test" "alice" False)
`shouldReturn` Left (PrivilegeError "bad_request" "invalid privilege for object scope")
it "applies PUBLIC CONNECT defaults and explicit inherited connections" $ withObjectPrivileges $ \_ service root -> do
authorizeConnect service (Ordinary testAccount) "test" `shouldReturn` Right ()
runPrivilegeCommand service root "" (RevokePrivilegesCommand ["connect"] "database:test" "public") `shouldReturn` Right ()
authorizeConnect service (Ordinary testAccount) "test" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: CONNECT ON database:test")
runPrivilegeCommand service root "" (CreateRoleCommand "connector") `shouldReturn` Right ()
runPrivilegeCommand service root "" (GrantPrivilegesCommand ["connect"] "database:test" "connector" False) `shouldReturn` Right ()
runPrivilegeCommand service root "" (GrantRoleCommand "connector" ["alice"]) `shouldReturn` Right ()
authorizeConnect service (Ordinary testAccount) "test" `shouldReturn` Right ()
runPrivilegeCommand service root "" (GrantPrivilegesCommand ["connect"] "database:test" "public" False) `shouldReturn` Right ()
it "grants CREATE without granting existing table access" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root "" (GrantPrivilegesCommand ["create"] "database:test" "alice" False) `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "CREATE TABLE owned (id INT)" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "gives table owners management and delegation without giving database management" $ withObjectPrivileges $ \_ service root -> do
claimObject service (Ordinary testAccount) testDatabase "users" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "DROP TABLE users" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "INSERT INTO users (id) VALUES (10)" `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "DROP DATABASE test" `shouldReturn` Left (PrivilegeError "forbidden" "object owner required")
runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "alice") `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
2026-10-11 17:32:12 +08:00
it "executes grant option revocation over TCP and keeps SELECT" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "USE test"
_ <- tcpQuery admin "CREATE USER option_reader IDENTIFIED BY 'Option-Reader-1234'"
tcpQuery admin "GRANT SELECT ON users TO option_reader WITH GRANT OPTION" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery admin "REVOKE GRANT OPTION FOR SELECT ON users FROM option_reader" >>= \reply -> at "status" reply `shouldBe` A.String "result"
withConnection handle $ \reader -> do
_ <- tcpLogin reader "option_reader" "Option-Reader-1234"
_ <- tcpQuery reader "USE test"
tcpQuery reader "SELECT DISTINCT age FROM users ORDER BY 1" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery reader "SHOW GRANTS" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery reader "GRANT SELECT ON users TO admin" >>= \reply -> at "code" reply `shouldBe` A.String "forbidden"
it "revokes grant option while preserving data access and independent sources" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount{accountUser = "bob"}) testDatabase (GrantPrivilegesCommand ["select"] "users" "carol" False) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "carol" False) `shouldReturn` Right ()
affectedAccounts service (RevokeGrantOptionCommand ["select"] "users" "alice") `shouldReturn` Right ["alice", "bob", "carol"]
runPrivilegeCommand service root testDatabase (RevokeGrantOptionCommand ["select"] "users" "alice") `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
grants <- listSelfGrants service (Ordinary testAccount)
grants `shouldSatisfy` either (const False) (elem (Grant "alice" "select" "test.users" False))
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
authorizeSql service (Ordinary testAccount{accountUser = "carol"}) "SELECT * FROM users" `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False)
`shouldReturn` Left (PrivilegeError "forbidden" "grant option required: SELECT ON users")
it "revokes only the caller's grant option source" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (RevokeGrantOptionCommand ["select"] "users" "bob") `shouldReturn` Right ()
grants <- listSelfGrants service (Ordinary testAccount{accountUser = "bob"})
grants `shouldSatisfy` either (const False) (elem (Grant "bob" "select" "test.users" True))
2026-10-05 22:22:23 +08:00
it "cascades multihop delegations after the source grant is revoked" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" True) `shouldReturn` Right ()
let bob = Ordinary testAccount{accountUser = "bob"}
runPrivilegeCommand service bob testDatabase (GrantPrivilegesCommand ["select"] "users" "carol" False) `shouldReturn` Right ()
affectedAccounts service (RevokePrivilegesCommand ["select"] "users" "alice") `shouldReturn` Right ["alice", "bob", "carol"]
runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "alice") `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount{accountUser = "carol"}) "SELECT * FROM users"
`shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "keeps an independent source when another delegation chain is revoked" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "alice") `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users" `shouldReturn` Right ()
it "removes delegations whose grantor loses an inherited source" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (CreateRoleCommand "delegator") `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "delegator" True) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (GrantRoleCommand "delegator" ["alice"]) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (RevokeRoleCommand "delegator" ["alice"]) `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "does not preserve circular delegation after its last independent root is revoked" $ withObjectPrivileges $ \_ service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount{accountUser = "bob"}) testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service root testDatabase (RevokePrivilegesCommand ["select"] "users" "alice") `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "never transfers a dropped table's grant to a same-name replacement" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" False) `shouldReturn` Right ()
mustRun (beStatement backend "DROP TABLE users")
mustRun (beStatement backend "CREATE TABLE users (id INT)")
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "does not let disabled grantors keep downstream grants alive" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" True) `shouldReturn` Right ()
runPrivilegeCommand service (Ordinary testAccount) testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
changed <- beAccounts backend (ReqIdentityAlter "alice" Nothing Nothing (Just False) Nothing Nothing)
either expectationFailure (const (pure ())) changed
authorizeSql service (Ordinary testAccount{accountUser = "bob"}) "SELECT * FROM users" `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "preserves dormant direct grants and ownership across unrelated ACL writes" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "alice" False) `shouldReturn` Right ()
mustRun (beStatement backend "CREATE TABLE owned (id INT)")
claimObject service (Ordinary testAccount) testDatabase "owned" `shouldReturn` Right ()
changed <- beAccounts backend (ReqIdentityAlter "alice" Nothing Nothing (Just False) Nothing Nothing)
either expectationFailure (const (pure ())) changed
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["select"] "users" "bob" False) `shouldReturn` Right ()
enabled <- beAccounts backend (ReqIdentityAlter "alice" Nothing Nothing (Just True) Nothing Nothing)
either expectationFailure (const (pure ())) enabled
authorizeSql service (Ordinary testAccount) "SELECT * FROM users" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "DROP TABLE owned" `shouldReturn` Right ()
it "keeps same-name tables in separate database scopes and persists the ACL" $ withIpcStorage $ do
backend <- ipcBackend
seedPrivilegeObjects backend
mustRun (beStatement backend "CREATE DATABASE other")
mustRun (beStatement (beWithDatabase backend "other") "CREATE TABLE users (id INT)")
service <- newPrivileges backend
runPrivilegeCommand service (Root testRootName) "test" (GrantPrivilegesCommand ["select"] "users" "alice" False) `shouldReturn` Right ()
statement <- parseOrFail "SELECT * FROM users"
authorize service (Ordinary testAccount) "other" statement `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
closeConnection
dir <- ipcDataDir
opened <- localStorageLink (Just (dir ++ ".toml"))
link <- either fail pure opened
setStorageLink link
fresh <- ipcBackend >>= newPrivileges
authorize fresh (Ordinary testAccount) "test" statement `shouldReturn` Right ()
authorize fresh (Ordinary testAccount) "other" statement `shouldReturn` Left (PrivilegeError "forbidden" "permission denied: SELECT ON users")
it "checks CONNECT and CREATE through TCP and assigns ownership to the creator" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "CREATE USER object_owner IDENTIFIED BY 'Owner-Secret-1234'"
tcpQuery admin "REVOKE CONNECT ON DATABASE test FROM PUBLIC" >>= \reply -> at "status" reply `shouldBe` A.String "result"
withConnection handle $ \client -> do
_ <- tcpLogin client "object_owner" "Owner-Secret-1234"
tcpQuery client "USE test" >>= \reply -> at "code" reply `shouldBe` A.String "forbidden"
_ <- tcpQuery admin "GRANT CONNECT, CREATE ON DATABASE test TO object_owner"
withConnection handle $ \client -> do
_ <- tcpLogin client "object_owner" "Owner-Secret-1234"
tcpQuery client "USE test" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery client "CREATE TABLE owned_probe (id INT)" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery client "INSERT INTO owned_probe (id) VALUES (1)" >>= \reply -> at "status" reply `shouldBe` A.String "result"
tcpQuery client "SELECT * FROM users" >>= \reply -> at "code" reply `shouldBe` A.String "forbidden"
tcpTalk client "{\"method\":\"storage\",\"request\":{\"method\":\"scan\",\"database\":\"test\",\"table\":\"users\"}}" >>= \reply -> at "code" reply `shouldBe` A.String "forbidden"
it "disconnects downstream TCP grantees and protects staged commits after revocation" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "USE test"
_ <- tcpQuery admin "CREATE USER source_user IDENTIFIED BY 'Source-Secret-1234'"
_ <- tcpQuery admin "CREATE USER target_user IDENTIFIED BY 'Target-Secret-1234'"
_ <- tcpQuery admin "GRANT SELECT, UPDATE ON users TO source_user WITH GRANT OPTION"
withConnection handle $ \source -> do
_ <- tcpLogin source "source_user" "Source-Secret-1234"
_ <- tcpQuery source "USE test"
_ <- tcpQuery source "GRANT SELECT, UPDATE ON users TO target_user"
withConnection handle $ \target -> do
_ <- tcpLogin target "target_user" "Target-Secret-1234"
_ <- tcpQuery target "USE test"
_ <- tcpQuery target "BEGIN"
_ <- tcpQuery target "UPDATE users SET age = 90 WHERE id = 1"
_ <- tcpQuery admin "REVOKE UPDATE ON users FROM source_user"
tcpQuery target "COMMIT" >>= \reply -> at "code" reply `shouldBe` A.String "unauthorized"
tcpClosed target
reply <- tcpQuery admin "SELECT age FROM users WHERE id = 1"
map items (items (at "rows" reply)) `shouldBe` [[A.Number 21]]
it "rejects a stale local transaction after a granted object is replaced" $ withObjectPrivileges $ \backend service root -> do
runPrivilegeCommand service root testDatabase (GrantPrivilegesCommand ["all"] "users" "alice" False) `shouldReturn` Right ()
_ <- beAccounts backend (ReqAccountReset "alice" (testPasswordHash "Alice-Secret-1234"))
settings <- tempSettingsPath "object-transaction"
_ <- beAccounts backend (ReqAccountCreate testRootName (testPasswordHash testRootPassword))
_ <- beAccounts backend (ReqIdentityAlter testRootName Nothing (Just True) Nothing Nothing Nothing)
session <- newSession backend testRootName settings
authenticateSessionCoded session "alice" "Alice-Secret-1234" `shouldReturn` Right ()
_ <- mustSql session "USE test"
_ <- mustSql session "BEGIN"
_ <- mustSql session "UPDATE users SET age = 90 WHERE id = 1"
mustRun (beStatement backend "DROP TABLE users")
mustRun (beStatement backend "CREATE TABLE users (id INT, name VARCHAR(32), age INT)")
result <- runStatementCoded session "COMMIT"
case result of Left err -> sessCode err `shouldBe` "forbidden"; Right _ -> expectationFailure "stale transaction committed"
it "checks the current administrator identity before executing a raw storage request" $ withObjectPrivileges $ \backend _ _ -> do
_ <- beAccounts backend (ReqAccountCreate testRootName (testPasswordHash testRootPassword))
_ <- beAccounts backend (ReqIdentityAlter testRootName Nothing (Just True) Nothing Nothing Nothing)
_ <- beAccounts backend (ReqAccountCreate "spare_admin" "hash")
_ <- beAccounts backend (ReqIdentityAlter "spare_admin" Nothing (Just True) Nothing Nothing Nothing)
executed <- newIORef False
let guarded = backend{beStorage = \_ -> writeIORef executed True >> pure (Right A.Null)}
settings <- tempSettingsPath "raw-storage-identity"
session <- newSession guarded testRootName settings
authenticateSessionCoded session testRootName testRootPassword `shouldReturn` Right ()
changed <- beAccounts backend (ReqIdentityAlter testRootName Nothing (Just False) Nothing Nothing Nothing)
either expectationFailure (const (pure ())) changed
result <- runStorageCoded session (A.object ["method" A..= ("ping" :: Text)])
case result of Left err -> sessCode err `shouldBe` "unauthorized"; Right _ -> expectationFailure "stale administrator executed storage request"
readIORef executed `shouldReturn` False
-- 为权限测试创建真实对象和登录身份。
seedPrivilegeObjects :: Backend -> IO ()
seedPrivilegeObjects backend = do
mustRun (beStatement backend "CREATE DATABASE test")
mustRun (beStatement (beWithDatabase backend "test") "CREATE TABLE users (id INT)")
result <- beAccounts backend (ReqAccountCreate "alice" "hash")
case result of Left err -> expectationFailure err; Right _ -> pure ()
2026-10-03 16:49:23 +08:00
-- | 本次用例的数据目录(用例串行跑,固定名够用)
ipcDataDir :: IO FilePath
ipcDataDir = do
tmp <- getTemporaryDirectory
pure (tmp </> "chusql-server-test-ipc")
-- | 写一份只属于这次用例的存储配置:数据目录与日志级别都从文件走
writeIpcConfig :: FilePath -> IO FilePath
writeIpcConfig dir = do
let cfgPath = dir ++ ".toml"
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
slashed = map (\c -> if c == '\\' then '/' else c) dir
writeFile
cfgPath
( unlines
[ "[storage]"
, "data_dir = \"" ++ slashed ++ "\""
, "[log]"
, "level = \"error\""
]
)
pure cfgPath
-- | 删掉这次用例的数据目录与配置,删不掉也不算错
cleanIpc :: FilePath -> IO ()
cleanIpc dir = do
_ <- try (removePathForcibly dir) :: IO (Either IOException ())
_ <- try (removePathForcibly (dir ++ ".toml")) :: IO (Either IOException ())
pure ()
-- 事务用例:内存后端跑得快,真存储的提交再在 IPC 用例里验一遍
-- | 起一个内存后端会话,当前库已切到 test
withMemorySession :: (Session -> IO a) -> IO a
withMemorySession body = do
db <- newMVar testDb
settings <- tempSettingsPath "transaction"
removeIfExists settings
session <- newSession (memoryBackend testDatabaseName db) (T.pack testDatabaseName) settings
_ <- runStatementCoded session "use test"
body session
-- | 两个会话共用一份内存库,用来验会话隔离
withTwoSessions :: (Session -> Session -> IO a) -> IO a
withTwoSessions body = do
db <- newMVar testDb
settings <- tempSettingsPath "transaction-pair"
removeIfExists settings
let backend = memoryBackend testDatabaseName db
first <- newSession backend (T.pack testDatabaseName) settings
second <- newSession backend (T.pack testDatabaseName) settings
_ <- runStatementCoded first "use test"
_ <- runStatementCoded second "use test"
body first second
-- | 跑一条语句;出错就算用例失败
mustSql :: Session -> Text -> IO QueryResult
mustSql session sql = runStatementCoded session sql >>= either failed pure
where
failed err = expectationFailure (T.unpack (sessMessage err)) >> pure (QueryResult [] [] 0 False Nothing)
-- | 取 users 的 id 列
sessionIds :: Session -> IO [Int]
sessionIds session = do
result <- mustSql session "select * from users"
pure [n | row <- qrRows result, Just (VInt n) <- [nth 0 row]]
-- | 取某个 id 的 age
sessionAge :: Session -> Int -> IO (Maybe Int)
sessionAge session wanted = do
result <- mustSql session "select * from users"
let ages = [(n, a) | row <- qrRows result, Just (VInt n) <- [nth 0 row], Just (VInt a) <- [nth 2 row]]
pure (lookup wanted ages)
-- | 取失败里的文案
failureMessage :: Either SessionError QueryResult -> Text
2026-10-15 22:21:41 +08:00
failureMessage = either sessMessage (const "")
2026-10-03 16:49:23 +08:00
2026-10-03 18:16:20 +08:00
-- | 取失败里的错误码
failureCode :: Either SessionError QueryResult -> Text
2026-10-15 22:21:41 +08:00
failureCode = either sessCode (const "")
2026-10-03 18:16:20 +08:00
2026-10-03 16:49:23 +08:00
-- | 取结果第一列的整数
intValues :: [[Value]] -> [Int]
intValues rows = [n | row <- rows, Just (VInt n) <- [nth 0 row]]
-- | 显式事务用例
transactionSpec :: Spec
transactionSpec = describe "server transactions" $ do
2026-10-11 17:32:12 +08:00
it "executes DISTINCT against the connection snapshot with output columns" $ withMemorySession $ \session -> do
_ <- mustSql session "CREATE TABLE distinct_values (id INT, value INT)"
_ <- mustSql session "INSERT INTO distinct_values (id,value) VALUES (1,7),(2,7),(3,8)"
_ <- mustSql session "BEGIN"
_ <- mustSql session "UPDATE distinct_values SET value = 9 WHERE id = 3"
result <- mustSql session "SELECT DISTINCT value FROM distinct_values ORDER BY 1"
qrColumns result `shouldBe` ["value"]
qrRows result `shouldBe` [[VInt 7], [VInt 9]]
_ <- mustSql session "ROLLBACK"
pure ()
2026-10-05 22:22:23 +08:00
it "preserves concurrent changes in a large untouched table" $ withTwoSessions $ \first second -> do
_ <- mustSql first "CREATE TABLE untouched (id INT, value INT)"
let values = T.intercalate "," [T.pack ("(" ++ show key ++ ",0)") | key <- [1 .. 5000 :: Int]]
_ <- mustSql first ("INSERT INTO untouched (id,value) VALUES " <> values)
_ <- mustSql first "BEGIN"
_ <- mustSql first "UPDATE users SET age = 77 WHERE id = 1"
_ <- mustSql second "UPDATE untouched SET value = 9 WHERE id = 5000"
_ <- mustSql first "COMMIT"
sessionAge first 1 `shouldReturn` Just 77
result <- mustSql first "SELECT value FROM untouched WHERE id = 5000"
qrRows result `shouldBe` [[VInt 9]]
it "preserves staged row order for unsorted integer ids" $ withMemorySession $ \session -> do
_ <- mustSql session "CREATE TABLE keys (id INT, value INT)"
_ <- mustSql session "INSERT INTO keys (id,value) VALUES (7,0),(-3,0),(4,0)"
_ <- mustSql session "BEGIN"
_ <- mustSql session "UPDATE keys SET value = 1"
_ <- mustSql session "COMMIT"
result <- mustSql session "SELECT id FROM keys"
qrRows result `shouldBe` [[VInt 7], [VInt (-3)], [VInt 4]]
it "filters qualified memory tables and preserves other databases on writes" $ do
2026-10-11 17:32:12 +08:00
let local = Table "users" [("id", TInt)] [[("id", VInt 1)]] Nothing Nothing Nothing
otherTable = Table "users" [("id", TInt)] [[("id", VInt 2)]] Nothing Nothing Nothing
ref <- newMVar [("test.users", local), ("other.users", otherTable)]
let backend = memoryBackend "test" ref
selected <- beStatement backend "SELECT id FROM users"
fmap srRows selected `shouldBe` Right [[("id", VInt 1)]]
tables <- beCatalog backend
fmap (map tiTable) tables `shouldBe` Right ["users"]
updated <- beStatement backend "UPDATE users SET id = 3"
2026-10-15 22:21:41 +08:00
void updated `shouldBe` Right ()
snapshot <- beSnapshot (memoryBackend "other" ref)
fmap (map (tableRows . snd)) snapshot `shouldBe` Right [[[ ("id", VInt 2) ]]]
2026-10-11 17:32:12 +08:00
it "keeps catalog and snapshot identities stable across system views and replacements" $ do
ref <- newMVar [("test.users", Table "users" [("id", TInt)] [] Nothing Nothing Nothing)]
let backend = memoryBackend "test" ref
first <- beCatalog backend
fmap (map tiIdentity) first `shouldBe` Right [Just (TableIdentity 1 1)]
_ <- beSnapshot (beWithDatabase backend "system")
saved <- beSnapshot backend
fmap (map (tableIdentity . snd)) saved `shouldBe` Right [Just (TableIdentity 1 1)]
mustRun (beStatement backend "DROP TABLE users")
mustRun (beStatement backend "CREATE TABLE users (id INT)")
replaced <- beCatalog backend
fmap (map tiIdentity) replaced `shouldBe` Right [Just (TableIdentity 1 2)]
it "preserves memory schema versions in catalogs and snapshots" $ do
ref <- newMVar []
let backend = memoryBackend "test" ref
2026-10-15 22:21:41 +08:00
void <$> beStatement backend "CREATE TABLE versioned (id INT)" `shouldReturn` Right ()
2026-10-11 17:32:12 +08:00
first <- beCatalog backend
fmap (map tiSchemaVersion) first `shouldBe` Right [Just 1]
2026-10-15 22:21:41 +08:00
void <$> beStatement backend "ALTER TABLE versioned ADD COLUMN extra TEXT" `shouldReturn` Right ()
void <$> beStatement backend "ALTER TABLE versioned DROP COLUMN extra" `shouldReturn` Right ()
2026-10-11 17:32:12 +08:00
current <- beCatalog backend
snapshot <- beSnapshot backend
fmap (map tiSchemaVersion) current `shouldBe` Right [Just 3]
fmap (map (tableSchemaVersion . snd)) snapshot `shouldBe` Right [Just 3]
it "rejects cross-database reads and writes including nested queries" $ withMemorySession $ \session -> do
mapM_ (\query -> do
result <- runStatementCoded session query
failureMessage result `shouldSatisfy` T.isInfixOf "outside the current database")
2026-10-11 17:32:12 +08:00
[ "SELECT DISTINCT age FROM other.users"
, "SELECT * FROM other.users"
, "UPDATE other.users SET age = 1"
, "DELETE FROM other.users"
, "INSERT INTO other.users (id) VALUES (99)"
, "SELECT * FROM users WHERE id IN (SELECT id FROM other.users)"
, "SELECT other.users.id FROM users"
]
void (mustSql session "BEGIN")
result <- runStatementCoded session "SELECT * FROM other.users"
failureMessage result `shouldSatisfy` T.isInfixOf "outside the current database"
void (mustSql session "ROLLBACK")
it "redacts malformed password statements through the session" $ withMemorySession $ \session -> do
result <- runStatementCoded session "ALTER USER alice IDENTIFIED BY 'private-secret' trailing"
failureMessage result `shouldSatisfy` T.isInfixOf "syntax"
failureMessage result `shouldSatisfy` (not . T.isInfixOf "private-secret")
it "resolves current database qualifiers for memory DDL" $ withMemorySession $ \session -> do
_ <- mustSql session "CREATE TABLE test.qualified (id INT)"
_ <- mustSql session "ALTER TABLE test.qualified ADD COLUMN value INT DEFAULT 8"
_ <- mustSql session "INSERT INTO qualified (id) VALUES (1)"
rows <- mustSql session "SELECT value FROM test.qualified"
intValues (qrRows rows) `shouldBe` [8]
void (mustSql session "DROP TABLE test.qualified")
it "uses domain columns in snapshot transactions and rejects domain DDL" $ withMemorySession $ \session -> do
_ <- mustSql session "create domain code as int"
_ <- mustSql session "create table typed (id code)"
_ <- mustSql session "begin"
denied <- runStatementCoded session "drop domain code"
failureMessage denied `shouldBe` "DDL is not allowed in a transaction"
_ <- mustSql session "insert into typed (id) values (7)"
_ <- mustSql session "commit"
rows <- mustSql session "select id from typed"
intValues (qrRows rows) `shouldBe` [7]
2026-10-03 16:49:23 +08:00
it "keeps uncommitted rows invisible to other sessions until COMMIT" $ withTwoSessions $ \writer reader -> do
_ <- mustSql writer "begin"
_ <- mustSql writer "insert into users (id, name, age) values (6, 'six', 26)"
sessionIds writer `shouldReturn` [1, 2, 3, 4, 5, 6]
sessionIds reader `shouldReturn` [1, 2, 3, 4, 5]
_ <- mustSql writer "commit"
ids <- sessionIds writer
ids `shouldMatchList` [1, 2, 3, 4, 5, 6]
seen <- sessionIds reader
seen `shouldMatchList` [1, 2, 3, 4, 5, 6]
it "drops every change on ROLLBACK" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "insert into users (id, name, age) values (6, 'six', 26)"
_ <- mustSql session "update users set age = 99 where id = 1"
_ <- mustSql session "delete from users where id = 2"
rollback <- mustSql session "rollback"
qrRowCount rollback `shouldBe` 0
kept <- sessionIds session
kept `shouldMatchList` [1, 2, 3, 4, 5]
it "commits the whole write set at once" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "insert into users (id, name, age) values (6, 'six', 26)"
_ <- mustSql session "update users set age = 99 where id = 1"
_ <- mustSql session "delete from users where id = 2"
_ <- mustSql session "commit"
ids <- sessionIds session
ids `shouldMatchList` [1, 3, 4, 5, 6]
sessionAge session 1 `shouldReturn` Just 99
it "rejects DDL inside a transaction and takes it after ROLLBACK" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
denied <- runStatementCoded session "create table blocked (id int)"
failureMessage denied `shouldBe` "DDL is not allowed in a transaction"
_ <- mustSql session "rollback"
_ <- mustSql session "create table fresh (id int)"
_ <- mustSql session "insert into fresh (id) values (1)"
rows <- mustSql session "select * from fresh"
qrRowCount rows `shouldBe` 1
2026-10-09 09:27:44 +08:00
it "does not start a transaction when its snapshot cannot be read" $ do
ref <- newMVar testDb
settings <- tempSettingsPath "snapshot-failure"
let base = memoryBackend testDatabaseName ref
-- | 保留测试后端并让快照读取失败
failing name = (beWithDatabase base name)
{ beSnapshot = pure (Left "data page unreadable")
, beWithDatabase = failing
}
session <- newSession (failing testDatabaseName) (T.pack testDatabaseName) settings
void (mustSql session "USE test")
result <- runStatementCoded session "BEGIN"
failureMessage result `shouldBe` "data page unreadable"
late <- runStatementCoded session "COMMIT"
failureMessage late `shouldBe` "no transaction in progress"
2026-10-03 16:49:23 +08:00
it "refuses nested BEGIN and COMMIT outside a transaction" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
nested <- runStatementCoded session "begin"
failureMessage nested `shouldBe` "already in a transaction"
_ <- mustSql session "rollback"
late <- runStatementCoded session "commit"
failureMessage late `shouldBe` "no transaction in progress"
2026-10-15 22:21:41 +08:00
it "rejects UPDATE constraints without changing or ending the transaction" $ withMemorySession $ \session -> do
_ <- mustSql session "CREATE TABLE constrained_updates (id INT PRIMARY KEY, qty INT NOT NULL UNIQUE)"
_ <- mustSql session "INSERT INTO constrained_updates (id, qty) VALUES (1, 2), (2, 3)"
_ <- mustSql session "BEGIN"
mapM_ (\(query, marker) -> do
result <- runStatementCoded session query
failureMessage result `shouldSatisfy` T.isInfixOf marker)
[("UPDATE constrained_updates SET qty = NULL WHERE id = 1", "NOT NULL")
, ("UPDATE constrained_updates SET qty = 3 WHERE id = 1", "UNIQUE")
, ("UPDATE constrained_updates SET id = 2 WHERE id = 1", "UNIQUE")]
unchanged <- mustSql session "SELECT qty FROM constrained_updates ORDER BY id"
intValues (qrRows unchanged) `shouldBe` [2, 3]
_ <- mustSql session "UPDATE constrained_updates SET qty = qty + 1"
_ <- mustSql session "COMMIT"
committed <- mustSql session "SELECT qty FROM constrained_updates ORDER BY id"
intValues (qrRows committed) `shouldBe` [3, 4]
2026-10-03 16:49:23 +08:00
it "does not switch database inside a transaction" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
denied <- runStatementCoded session "use other"
failureMessage denied `shouldBe` "cannot switch database inside a transaction"
2026-10-03 18:16:20 +08:00
it "refuses COMMIT when another session changed the same row" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "update users set age = 31 where id = 1"
_ <- mustSql second "update users set age = 42 where id = 1"
_ <- mustSql first "commit"
denied <- runStatementCoded second "commit"
failureCode denied `shouldBe` "serialization_failure"
failureMessage denied `shouldBe` "write conflict on table users, id 1"
-- 事务保留:回滚之后看到的还是先提交的那个值
_ <- mustSql second "rollback"
sessionAge second 1 `shouldReturn` Just 31
it "refuses COMMIT when the row was deleted meanwhile" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "delete from users where id = 2"
_ <- mustSql second "update users set age = 42 where id = 2"
_ <- mustSql first "commit"
denied <- runStatementCoded second "commit"
failureMessage denied `shouldBe` "write conflict on table users, id 2"
_ <- mustSql second "rollback"
ids <- sessionIds second
ids `shouldMatchList` [1, 3, 4, 5]
it "refuses COMMIT when deleting a row that changed meanwhile" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "update users set age = 31 where id = 3"
_ <- mustSql second "delete from users where id = 3"
_ <- mustSql first "commit"
denied <- runStatementCoded second "commit"
failureMessage denied `shouldBe` "write conflict on table users, id 3"
_ <- mustSql second "rollback"
sessionAge second 3 `shouldReturn` Just 31
it "refuses COMMIT when another session inserted the same id" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "insert into users (id, name, age) values (7, 'seven', 27)"
_ <- mustSql second "insert into users (id, name, age) values (7, 'other', 70)"
_ <- mustSql first "commit"
denied <- runStatementCoded second "commit"
failureMessage denied `shouldBe` "write conflict on table users, id 7"
_ <- mustSql second "rollback"
ids <- sessionIds second
ids `shouldMatchList` [1, 2, 3, 4, 5, 7]
it "keeps other sessions' rows untouched when the write sets do not overlap" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "insert into users (id, name, age) values (6, 'six', 26)"
_ <- mustSql first "update users set age = 31 where id = 1"
_ <- mustSql second "update users set age = 42 where id = 2"
_ <- mustSql first "commit"
_ <- mustSql second "commit"
sessionAge second 1 `shouldReturn` Just 31
sessionAge second 2 `shouldReturn` Just 42
ids <- sessionIds second
ids `shouldMatchList` [1, 2, 3, 4, 5, 6]
it "lets both sessions delete the same row" $ withTwoSessions $ \first second -> do
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "delete from users where id = 4"
_ <- mustSql second "delete from users where id = 4"
_ <- mustSql first "commit"
_ <- mustSql second "commit"
ids <- sessionIds second
ids `shouldMatchList` [1, 2, 3, 5]
2026-10-03 19:12:11 +08:00
it "keeps the work before a savepoint and drops the work after it" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "update users set age = 31 where id = 1"
_ <- mustSql session "savepoint spot"
_ <- mustSql session "insert into users (id, name, age) values (6, 'six', 26)"
_ <- mustSql session "delete from users where id = 2"
_ <- mustSql session "rollback to spot"
inside <- sessionIds session
inside `shouldMatchList` [1, 2, 3, 4, 5]
sessionAge session 1 `shouldReturn` Just 31
_ <- mustSql session "commit"
ids <- sessionIds session
ids `shouldMatchList` [1, 2, 3, 4, 5]
sessionAge session 1 `shouldReturn` Just 31
it "lets a savepoint be used again after rolling back to it" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "insert into users (id, name, age) values (6, 'six', 26)"
_ <- mustSql session "savepoint spot"
_ <- mustSql session "insert into users (id, name, age) values (7, 'seven', 27)"
_ <- mustSql session "rollback to spot"
_ <- mustSql session "insert into users (id, name, age) values (8, 'eight', 28)"
ids <- sessionIds session
ids `shouldMatchList` [1, 2, 3, 4, 5, 6, 8]
it "drops the savepoints above the one rolled back to" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "savepoint one"
_ <- mustSql session "savepoint two"
_ <- mustSql session "rollback to one"
missing <- runStatementCoded session "rollback to two"
failureMessage missing `shouldBe` "no such savepoint: two"
it "drops a released savepoint and the ones after it" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "savepoint one"
_ <- mustSql session "savepoint two"
released <- mustSql session "release one"
qrRowCount released `shouldBe` 0
first <- runStatementCoded session "rollback to one"
failureMessage first `shouldBe` "no such savepoint: one"
second <- runStatementCoded session "rollback to two"
failureMessage second `shouldBe` "no such savepoint: two"
it "clears the savepoints on COMMIT" $ withMemorySession $ \session -> do
_ <- mustSql session "begin"
_ <- mustSql session "savepoint spot"
_ <- mustSql session "commit"
missing <- runStatementCoded session "rollback to spot"
failureMessage missing `shouldBe` "no transaction in progress"
it "needs a transaction and a known name for savepoints" $ withMemorySession $ \session -> do
outside <- runStatementCoded session "savepoint spot"
failureMessage outside `shouldBe` "no transaction in progress"
_ <- mustSql session "begin"
unknown <- runStatementCoded session "rollback to nowhere"
failureMessage unknown `shouldBe` "no such savepoint: nowhere"
gone <- runStatementCoded session "release nowhere"
failureMessage gone `shouldBe` "no such savepoint: nowhere"
2026-10-03 16:49:23 +08:00
it "needs a current database to start" $ do
db <- newMVar testDb
settings <- tempSettingsPath "transaction-bare"
removeIfExists settings
session <- newSession (memoryBackend testDatabaseName db) (T.pack testDatabaseName) settings
result <- runStatementCoded session "begin"
failureMessage result `shouldBe` "no database selected"
-- | 起一份真存储并装成当前链路;动态库打不开就当用例待定
withIpcStorage :: IO () -> IO ()
withIpcStorage body = do
dir <- ipcDataDir
cleanIpc dir
createDirectoryIfMissing True dir
cfgPath <- writeIpcConfig dir
opened <- localStorageLink (Just cfgPath)
case opened of
Left err -> cleanIpc dir >> pendingWith ("cannot open the storage library: " ++ err)
Right link -> do
setStorageLink link
base <- ipcBackend
seeded <- beStorage base (A.object ["method" A..= ("bootstrap_system" :: Text), "user" A..= ("root" :: Text), "password_hash" A..= ("hash" :: Text)])
case seeded of
Left err -> expectationFailure err
Right value -> case value of
A.Object fields -> KM.lookup "initialized" fields `shouldBe` Just (A.Bool True)
_ -> expectationFailure "invalid bootstrap response"
2026-10-03 16:49:23 +08:00
body `finally` (closeConnection >> cleanIpc dir)
-- | 跑一条语句,出错就算用例失败
mustRun :: IO (Either String StatementResult) -> IO ()
mustRun action = action >>= either expectationFailure (const (pure ()))
-- | 取结果行,出错就算用例失败
rowsOf :: IO (Either String StatementResult) -> IO [Row]
rowsOf action = action >>= either (\err -> expectationFailure err >> pure []) (pure . srRows)
-- | 取结果里某一列的整数
intColumn :: String -> [Row] -> [Int]
intColumn name rows = [n | row <- rows, Just (VInt n) <- [lookup name row]]
-- | 往 p 表连插一批行,结果放 MVar 交回主线程
writeRows :: Backend -> [Int] -> MVar [Either String StatementResult] -> IO ()
writeRows session ids done = do
outs <- mapM (\i -> beStatement session ("INSERT INTO p (id) VALUES (" ++ show i ++ ")")) ids
putMVar done outs
-- | 同一进程里开两个库:会话之间不串数据,也不互相挡路
ipcConcurrencySpec :: Spec
ipcConcurrencySpec = describe "server backend on the real storage" $ do
it "resolves DDL qualifiers without crossing database boundaries" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
unselected <- beStatement base "CREATE TABLE alpha.t (id INT)"
2026-10-15 22:21:41 +08:00
void unselected `shouldBe` Left "no database selected"
mustRun (beStatement alpha "CREATE TABLE alpha.t (id INT, value INT)")
duplicate <- beStatement alpha "CREATE TABLE t (id INT)"
2026-10-15 22:21:41 +08:00
void duplicate `shouldSatisfy` either (const True) (const False)
mustRun (beStatement alpha "INSERT INTO t (id, value) VALUES (1, 7)")
mustRun (beStatement alpha "CREATE INDEX ON alpha.t (value)")
mustRun (beStatement alpha "DROP INDEX ON alpha.t (value)")
mustRun (beStatement alpha "ALTER TABLE alpha.t RENAME COLUMN value TO score")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score TYPE BIGINT")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score SET DEFAULT 9")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score SET NOT NULL")
rows <- rowsOf (beStatement alpha "SELECT score FROM t")
intColumn "score" rows `shouldBe` [7]
crossed <- beStatement beta "DROP TABLE alpha.t"
2026-10-15 22:21:41 +08:00
void crossed `shouldBe` Left "DDL target is outside the current database: alpha.t"
mustRun (beStatement alpha "ALTER TABLE alpha.t DROP COLUMN score")
mustRun (beStatement alpha "DROP TABLE alpha.t")
catalog <- beCatalog alpha >>= either fail pure
map tiTable catalog `shouldBe` []
it "isolates domain catalogs by database and hides them from table lists" $ withIpcStorage $ do
base <- ipcBackend
unselected <- beStatement base "SHOW DOMAINS"
2026-10-15 22:21:41 +08:00
void unselected `shouldBe` Left "no database selected"
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE DOMAIN code AS INT")
mustRun (beStatement beta "CREATE DOMAIN code AS VARCHAR(3)")
mustRun (beStatement alpha "CREATE TABLE typed (id code)")
mustRun (beStatement beta "CREATE TABLE typed (id code)")
mustRun (beStatement alpha "INSERT INTO typed (id) VALUES (1)")
mustRun (beStatement beta "INSERT INTO typed (id) VALUES ('abc')")
catalog <- beCatalog alpha >>= either fail pure
map tiTable catalog `shouldBe` ["typed"]
domains <- rowsOf (beStatement beta "SHOW DOMAINS")
domains `shouldBe` [[("domain", VStr "code"), ("base_type", VStr "varchar(3)")]]
refused <- beStatement beta "INSERT INTO typed (id) VALUES (1)"
2026-10-15 22:21:41 +08:00
void refused `shouldSatisfy` either (const True) (const False)
it "resolves nested domains only inside their own database" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE DOMAIN label AS VARCHAR(3)")
mustRun (beStatement alpha "CREATE DOMAIN product_label AS label")
absent <- beStatement beta "CREATE DOMAIN product_label AS label"
2026-10-15 22:21:41 +08:00
void absent `shouldBe` Left "unknown domain: label"
mustRun (beStatement alpha "CREATE TABLE products (name product_label)")
mustRun (beStatement alpha "INSERT INTO products (name) VALUES ('abc')")
rowsOf (beStatement alpha "SELECT name FROM products") `shouldReturn` [[("name", VStr "abc")]]
dependency <- beStatement alpha "DROP DOMAIN label"
2026-10-15 22:21:41 +08:00
void dependency `shouldSatisfy` either (const True) (const False)
2026-10-03 16:49:23 +08:00
it "keeps two databases apart" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE TABLE t (id int)")
mustRun (beStatement beta "CREATE TABLE t (id int)")
mustRun (beStatement alpha "INSERT INTO t (id) VALUES (1)")
mustRun (beStatement beta "INSERT INTO t (id) VALUES (2)")
rowsA <- rowsOf (beStatement alpha "SELECT id FROM t")
rowsB <- rowsOf (beStatement beta "SELECT id FROM t")
intColumn "id" rowsA `shouldBe` [1]
intColumn "id" rowsB `shouldBe` [2]
it "runs two sessions at once without mixing their rows" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE TABLE p (id int)")
mustRun (beStatement beta "CREATE TABLE p (id int)")
doneA <- newEmptyMVar
doneB <- newEmptyMVar
_ <- forkIO (writeRows alpha [1 .. 20] doneA)
_ <- forkIO (writeRows beta [101 .. 120] doneB)
outs <- sequence [takeMVar doneA, takeMVar doneB]
2026-10-15 22:21:41 +08:00
lefts (concat outs) `shouldBe` []
2026-10-03 16:49:23 +08:00
rowsA <- rowsOf (beStatement alpha "SELECT id FROM p")
rowsB <- rowsOf (beStatement beta "SELECT id FROM p")
intColumn "id" rowsA `shouldMatchList` [1 .. 20]
intColumn "id" rowsB `shouldMatchList` [101 .. 120]
it "commits an explicit transaction to the real storage" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
let alpha = beWithDatabase base "alpha"
settings <- tempSettingsPath "transaction-ipc"
removeIfExists settings
session <- newSession alpha (T.pack testDatabaseName) settings
_ <- mustSql session "use alpha"
_ <- mustSql session "create table t (id int)"
_ <- mustSql session "begin"
_ <- mustSql session "insert into t (id) values (1)"
inside <- mustSql session "select * from t"
intValues (qrRows inside) `shouldBe` [1]
_ <- mustSql session "commit"
rows <- rowsOf (beStatement alpha "SELECT id FROM t")
intColumn "id" rows `shouldBe` [1]
2026-10-03 19:12:11 +08:00
it "rolls back to a savepoint on the real storage" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
let alpha = beWithDatabase base "alpha"
settings <- tempSettingsPath "transaction-ipc-savepoint"
removeIfExists settings
session <- newSession alpha (T.pack testDatabaseName) settings
_ <- mustSql session "use alpha"
_ <- mustSql session "create table t (id int)"
_ <- mustSql session "begin"
_ <- mustSql session "insert into t (id) values (1)"
_ <- mustSql session "savepoint spot"
_ <- mustSql session "insert into t (id) values (2)"
_ <- mustSql session "rollback to spot"
inside <- mustSql session "select * from t"
intValues (qrRows inside) `shouldBe` [1]
_ <- mustSql session "commit"
rows <- rowsOf (beStatement alpha "SELECT id FROM t")
intColumn "id" rows `shouldBe` [1]
2026-10-03 18:16:20 +08:00
it "refuses the second COMMIT on the real storage" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
let alpha = beWithDatabase base "alpha"
mustRun (beStatement alpha "CREATE TABLE users (id int, name text, age int)")
mustRun (beStatement alpha "INSERT INTO users (id, name, age) VALUES (1, 'a', 21)")
settings <- tempSettingsPath "transaction-ipc-conflict"
removeIfExists settings
first <- newSession alpha (T.pack testDatabaseName) settings
second <- newSession alpha (T.pack testDatabaseName) settings
_ <- mustSql first "use alpha"
_ <- mustSql second "use alpha"
_ <- mustSql first "begin"
_ <- mustSql second "begin"
_ <- mustSql first "update users set age = 31 where id = 1"
_ <- mustSql second "update users set age = 42 where id = 1"
_ <- mustSql first "commit"
denied <- runStatementCoded second "commit"
failureCode denied `shouldBe` "serialization_failure"
failureMessage denied `shouldBe` "write conflict on table users, id 1"
_ <- mustSql second "rollback"
rows <- rowsOf (beStatement alpha "SELECT id, age FROM users")
intColumn "id" rows `shouldBe` [1]
intColumn "age" rows `shouldBe` [31]
2026-10-05 22:22:23 +08:00
-- 判断明确错误返回。
isLeftResult :: Either a b -> Bool
isLeftResult (Left _) = True
isLeftResult _ = False
-- 启动带独立本机认证凭据的测试会话。
withTestSudo :: (ServerEnv -> ServerHandle -> SudoCredential -> IO a) -> IO a
withTestSudo body = withTcpServer defaultServerConfig $ \env handle -> do
credential <- newSudoCredential
withConnection handle $ \admin -> do
_ <- tcpLogin admin testRootName testRootPassword
created <- tcpQuery admin "create user local_user identified by 'Local-Pass123!'"
at "status" created `shouldBe` A.String "result"
enabled <- tcpQuery admin "alter user local_user allow_sudo_auth"
at "status" enabled `shouldBe` A.String "result"
modifyMVar_ (srvSudo env) (const (pure (Just (0, "local_user", credential))))
body env handle credential `finally` modifyMVar_ (srvSudo env) (const (pure Nothing))
-- 交换本机挑战并提交账号绑定证明。
tcpSudo :: Handle -> SudoCredential -> Text -> IO A.Value
tcpSudo client credential user = do
challenge <- tcpTalk client "{\"method\":\"sudo_challenge\"}"
tcpSudoProof client user (sudoProof credential (asText (at "challenge" challenge)) user)
-- 发送本机认证证明。
tcpSudoProof :: Handle -> Text -> Text -> IO A.Value
tcpSudoProof client user proof = tcpTalk client (BL.toStrict (A.encode (A.object
["method" A..= ("sudo_login" :: Text), "user" A..= user, "proof" A..= proof])))
-- 验证身份禁用或取消登录后拒绝本机认证。
checkSudoDisabled :: Handle -> ServerHandle -> SudoCredential -> Text -> IO ()
checkSudoDisabled admin handle credential sql = do
changed <- tcpQuery admin sql
at "status" changed `shouldBe` A.String "result"
withConnection handle $ \client -> do
denied <- tcpSudo client credential "local_user"
at "code" denied `shouldBe` A.String "unauthorized"
-- 判断明确成功返回。
isRightResult :: Either a b -> Bool
isRightResult (Right _) = True
isRightResult _ = False
2026-10-11 17:32:12 +08:00
-- 专用范式协议、权限、快照与预算回归。
normalizationSpec :: Spec
normalizationSpec = describe "normalization endpoint" $ do
it "round-trips versioned requests and rejects unknown and duplicate fields" $ do
decodeRequest (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload))) `shouldBe` Right (ReqNormalization normalizationPayload)
let large = ObjectIdentity 9007199254740993 18446744073709551615
A.fromJSON (A.toJSON large) `shouldBe` A.Success large
case A.eitherDecode "{\"database_id\":1e1000000000,\"object_id\":2}" :: Either String A.Value of
Left message -> expectationFailure message
Right value -> (A.fromJSON value :: A.Result ObjectIdentity) `shouldSatisfy` isJSONFailure
report <- either (fail . show) pure (normalizationReport (ObjectIdentity 1 2) [SchemaColumn name "int" True Nothing False False False Nothing | name <- ["id", "name", "age"]] "live_schema" normalizationPayload)
let response = RespNormalization report
decodeResponse (encodeResponse response) `shouldBe` Right response
decodeResponse (encodeResponse (RespNormalization (A.object ["conditional" A..= True]))) `shouldSatisfy` either (const True) (const False)
mapM_ (\raw -> decodeNormalizationJSON raw `shouldSatisfy` either (T.isInfixOf "duplicate") (const False))
[ "{\"a\":1,\"a\":2}", "{\"a\":1,\"\\u0061\":2}", "{\"a\":[{\"b\":1,\"b\":2}]}" ]
let unknown = A.object ["api_version" A..= (1 :: Int), "database" A..= ("test" :: Text), "table" A..= ("users" :: Text), "declaration" A..= nrDeclaration normalizationPayload, "ignored" A..= True]
(A.fromJSON unknown :: A.Result NormalizationRequest) `shouldSatisfy` isJSONFailure
decodeRequest (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload{nrVersion = 2}))) `shouldSatisfy` either (const True) (const False)
decodeRequest (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload{nrTable = "other.users"}))) `shouldSatisfy` either (const True) (const False)
it "does not infer completeness or atomicity and validates declaration budgets" $ do
(A.fromJSON (A.object []) :: A.Result NormalizationDeclaration) `shouldBe` A.Success (NormalizationDeclaration [] [] False Nothing)
let many = NormalizationDeclaration (replicate 257 ["id"]) [] True (Just True)
(A.fromJSON (A.toJSON many) :: A.Result NormalizationDeclaration) `shouldSatisfy` isJSONFailure
decodeNormalizationJSON (BL.replicate 65537 32) `shouldSatisfy` either (const True) (const False)
(A.fromJSON (A.object ["dependencies" A..= [A.object ["lhs" A..= (["id"] :: [Text]), "rhs" A..= ([] :: [Text])]]]) :: A.Result NormalizationDeclaration) `shouldSatisfy` isJSONFailure
it "authorizes ordinary SELECT users and refuses anonymous or system requests" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \admin -> do
anonymous <- tcpTalk admin (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload)))
at "code" anonymous `shouldBe` A.String "unauthorized"
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "USE test"
_ <- tcpQuery admin "CREATE USER normalization_reader IDENTIFIED BY 'Reader-Secret-1234'"
withConnection handle $ \reader -> do
_ <- tcpLogin reader "normalization_reader" "Reader-Secret-1234"
_ <- tcpQuery reader "USE test"
denied <- tcpTalk reader (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload)))
at "code" denied `shouldBe` A.String "forbidden"
_ <- tcpQuery admin "GRANT SELECT ON users TO normalization_reader"
withConnection handle $ \reader -> do
_ <- tcpLogin reader "normalization_reader" "Reader-Secret-1234"
_ <- tcpQuery reader "USE test"
reply <- tcpTalk reader (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload)))
at "status" reply `shouldBe` A.String "normalization"
at "conditional" (at "analysis" reply) `shouldBe` A.Bool True
length (items (at "findings" (at "analysis" reply))) `shouldBe` 4
denied <- tcpTalk reader (BL.toStrict (encodeRequest (ReqNormalization normalizationPayload{nrTable = "__system_domain_secret"})))
at "code" denied `shouldBe` A.String "forbidden"
it "uses only the caller snapshot and refuses stable object replacement" $
withTcpServer defaultServerConfig $ \_ handle -> withConnection handle $ \first -> withConnection handle $ \second -> do
_ <- tcpLogin first "admin" "s3cret"
_ <- tcpLogin second "admin" "s3cret"
_ <- tcpQuery first "USE test"
_ <- tcpQuery second "USE test"
_ <- tcpQuery first "CREATE TABLE normalization_snapshot (id INT, value INT)"
_ <- tcpQuery first "BEGIN"
_ <- tcpQuery second "ALTER TABLE normalization_snapshot ADD COLUMN added INT"
let payload = normalizationPayload{nrTable = "normalization_snapshot", nrDeclaration = NormalizationDeclaration [] [] False Nothing}
old <- tcpTalk first (BL.toStrict (encodeRequest (ReqNormalization payload)))
current <- tcpTalk second (BL.toStrict (encodeRequest (ReqNormalization payload)))
at "view" (at "analysis" old) `shouldBe` A.String "transaction_snapshot"
length (items (at "schema" (at "analysis" old))) `shouldBe` 2
length (items (at "schema" (at "analysis" current))) `shouldBe` 3
at "schema_fingerprint" (at "analysis" old) `shouldNotBe` at "schema_fingerprint" (at "analysis" current)
_ <- tcpQuery second "DROP TABLE normalization_snapshot"
_ <- tcpQuery second "CREATE TABLE normalization_snapshot (id INT, value INT)"
stale <- tcpTalk first (BL.toStrict (encodeRequest (ReqNormalization payload)))
at "code" stale `shouldBe` A.String "conflict"
_ <- tcpQuery first "ROLLBACK"
pure ()
it "checks expected schema fingerprints and budgets the complete output" $ do
let columns = [SchemaColumn ("c" ++ show n) "int" True Nothing False False False Nothing | n <- [0 .. 9 :: Int]]
payload = normalizationPayload{nrDeclaration = NormalizationDeclaration [] [NormalizationDependency [] ["c9"]] True (Just True)}
schemaFingerprint (ObjectIdentity 1 2) columns `shouldNotBe` schemaFingerprint (ObjectIdentity 1 3) columns
normalizationReport (ObjectIdentity 1 2) columns "live_schema" payload{nrExpectedSchema = Just "outdated"}
`shouldBe` Left ("conflict", "normalization object or schema has changed")
result <- normalizationBudget (pure (normalizationReport (ObjectIdentity 1 2) columns "live_schema" payload))
result `shouldSatisfy` either ((== "too_large") . fst) (const False)
it "bounds the largest schema with maximum declared dependencies" $ do
let columns = [SchemaColumn ("c" ++ show n) "int" True Nothing False False False Nothing | n <- [0 .. 11 :: Int]]
payload = normalizationPayload{nrDeclaration = NormalizationDeclaration [] (replicate 256 (NormalizationDependency [] ["c11"])) True (Just True)}
started <- getCurrentTime
result <- normalizationBudget (pure (normalizationReport (ObjectIdentity 1 2) columns "live_schema" payload))
ended <- getCurrentTime
putStrLn ("normalization maximum input seconds: " ++ show (ended `diffUTCTime` started))
result `shouldSatisfy` either ((`elem` ["too_large", "timeout"]) . fst) (const False)
normalizationBudget (pure (Right A.Null)) `shouldReturn` Right A.Null
it "releases admission and execution slots after cancellation" $ do
started <- newEmptyMVar
blocker <- newEmptyMVar
done <- newEmptyMVar
worker <- forkFinally (normalizationBudget (putMVar started () >> takeMVar blocker >> pure (Right A.Null))) (putMVar done)
takeMVar started
killThread worker
takeMVar done >>= either (const (pure ())) (const (expectationFailure "cancelled analysis completed"))
ready <- newEmptyMVar
release <- newEmptyMVar
finished <- newEmptyMVar
replicateM_ 2 (void (forkFinally (normalizationBudget (putMVar ready () >> takeMVar release >> pure (Right A.Null))) (putMVar finished)))
mapM_ (\_ -> timeout 1000000 (takeMVar ready) >>= (`shouldBe` Just ())) [1, 2 :: Int]
replicateM_ 2 (putMVar release ())
replicateM_ 2 (takeMVar finished >>= either (expectationFailure . show) (`shouldBe` Right A.Null))
it "times out work and leaves the service usable" $ do
result <- normalizationBudget (threadDelay 6000000 >> pure (Right A.Null))
result `shouldSatisfy` either ((== "timeout") . fst) (const False)
normalizationBudget (pure (Right A.Null)) `shouldReturn` Right A.Null
-- 共用明确声明的用户表请求。
normalizationPayload :: NormalizationRequest
normalizationPayload = NormalizationRequest 1 "test" "users"
(NormalizationDeclaration [["id"]] [NormalizationDependency ["id"] ["name", "age"]] True (Just True)) Nothing Nothing
-- 判断 JSON 类型校验失败。
isJSONFailure :: A.Result a -> Bool
isJSONFailure (A.Error _) = True
isJSONFailure _ = False