diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index a716197..be1378d 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,8 +1,8 @@ # Linux 流水线:Rust 存储 + Haskell 引擎/Web/CLI 的构建与测试。 # # 这里每条命令都在 WSL Debian(cargo 1.98 / GHC 9.10.3 / stack 3.11.1)上跑通过, -# 见仓库根 README 的「构建与测试」段。IPC 用例需要 chusql-storage 可执行文件, -# 所以 Rust 先构建,Haskell 的 engine/web 用例才能真跑而不是 pending。 +# 见仓库根 README 的「构建与测试」段。Haskell 这边链接并装入 libchusql_core_storage.so, +# 所以 Rust 先构建,LD_LIBRARY_PATH 指向它的输出目录,Haskell 用例才能真跑而不是 pending。 # 打包和发版资源走 .github/workflows/release.yml(tag 触发,也可手动 dispatch)。 name: linux @@ -18,6 +18,7 @@ concurrency: env: CARGO_TERM_COLOR: always + LD_LIBRARY_PATH: ${{ github.workspace }}/chusql-core/storage/target/release jobs: linux: @@ -39,24 +40,20 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - chusql-storage/target - key: ${{ runner.os }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }} + chusql-core/storage/target + key: ${{ runner.os }}-cargo-${{ hashFiles('chusql-core/storage/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - name: cargo build (release) - working-directory: chusql-storage + working-directory: chusql-core/storage run: cargo build --release - - name: cargo build (debug, IPC 用例要它) - working-directory: chusql-storage - run: cargo build --bin chusql-storage - - name: cargo test - working-directory: chusql-storage + working-directory: chusql-core/storage run: cargo test --release - name: cargo clippy (-D warnings) - working-directory: chusql-storage + working-directory: chusql-core/storage run: cargo clippy --all-targets -- -D warnings # 故意不做 rustfmt 检查:仓库里没有 rustfmt.toml,历史代码也不是按当前 rustfmt 的 @@ -76,7 +73,8 @@ jobs: with: path: | ~/.stack - chusql-engine/.stack-work + chusql-core/engine/.stack-work + chusql-server/.stack-work chusql-web/.stack-work chusql-cli/.stack-work key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }} @@ -87,7 +85,11 @@ jobs: run: stack build --fast - name: engine tests - working-directory: chusql-engine + working-directory: chusql-core/engine + run: stack test --fast + + - name: server tests + working-directory: chusql-server run: stack test --fast - name: web tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8083dca..d510807 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,11 @@ -# 发版流水线:打 tag(v*)时为四个平台各出 web / cli 两个发行包,附 sha256,挂到 Release。 +# 发版流水线:打 tag(v*)时为四个平台各出一个发行包,附 sha256,挂到 Release。 # -# 资源命名(install.sh 按 - 挑资源,见 scripts/install.sh): -# chusql-web-linux-x86_64.tar.gz chusql-cli-linux-x86_64.tar.gz -# chusql-web-windows-x86_64.zip chusql-cli-windows-x86_64.zip -# chusql-web-macos-x86_64.tar.gz chusql-cli-macos-x86_64.tar.gz -# chusql-web-macos-arm64.tar.gz chusql-cli-macos-arm64.tar.gz +# 资源命名(install.sh / install.ps1 按 - 挑资源,见 scripts/install.sh): +# chusql-linux-x86_64.tar.gz chusql-windows-x86_64.zip +# chusql-macos-x86_64.tar.gz chusql-macos-arm64.tar.gz +# +# 一个平台一个包,里面 Web 前端与命令行客户端两部分都在;装哪几个由包内的安装脚本 +# 当场问(非交互时用 --component / -Component 指定)。 # # 装的人拿到的就是编好的二进制,不需要 GHC / Rust: # curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh \ @@ -33,9 +34,9 @@ env: jobs: build: - name: ${{ matrix.platform }} / ${{ matrix.component }} + name: ${{ matrix.platform }} runs-on: ${{ matrix.os }} - timeout-minutes: 120 + timeout-minutes: 150 strategy: fail-fast: false matrix: @@ -44,7 +45,6 @@ jobs: - { os: windows-latest, platform: windows-x86_64 } - { os: macos-13, platform: macos-x86_64 } - { os: macos-14, platform: macos-arm64 } - component: [web, cli] steps: - uses: actions/checkout@v4 @@ -58,8 +58,8 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - chusql-storage/target - key: ${{ runner.os }}-${{ matrix.platform }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }} + chusql-core/storage/target + key: ${{ runner.os }}-${{ matrix.platform }}-cargo-${{ hashFiles('chusql-core/storage/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - name: Haskell toolchain @@ -74,7 +74,7 @@ jobs: with: path: | ~/.stack - chusql-engine/.stack-work + chusql-core/engine/.stack-work chusql-web/.stack-work chusql-cli/.stack-work key: ${{ runner.os }}-${{ matrix.platform }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }} @@ -82,59 +82,62 @@ jobs: # ---------- 构建 ---------- - name: build storage (Rust) - working-directory: chusql-storage + working-directory: chusql-core/storage run: cargo build --release - name: build web front end - if: matrix.component == 'web' working-directory: chusql-cli run: stack build --fast chusql-web:exe:chusql-web - name: build cli - if: matrix.component == 'cli' working-directory: chusql-cli run: stack build --fast chusql-cli:exe:csql - # ---------- 装配 + 打归档(原来放在一个独立的打包脚本里,那个脚本已经删掉)---------- + - name: build tcp server + working-directory: chusql-cli + run: stack build --fast chusql-server:exe:chusql-server + + # ---------- 装配 + 打归档(CI 里直接写在这里;本机打同一个包用 scripts/package.sh)---------- # 归档里是包内容本身(bin/、scripts/、static/、install.sh 就在根上),安装脚本按这个布局找文件。 - name: package (Unix) if: runner.os != 'Windows' shell: bash run: | set -eu - component='${{ matrix.component }}' - name="chusql-$component-${{ matrix.platform }}" + name="chusql-${{ matrix.platform }}" stage="dist/$name" rm -rf "$stage" mkdir -p "$stage/bin" "$stage/scripts" - cp chusql-storage/target/release/chusql-storage "$stage/bin/" + cp chusql-core/storage/target/release/libchusql_core_storage.* "$stage/bin/" stack_root="$(cd chusql-cli && stack path --local-install-root | tail -n 1 | tr -d '\r')" - if [ "$component" = web ]; then front_name=chusql-web; else front_name=csql; fi - front="$stack_root/bin/$front_name" - if [ ! -f "$front" ]; then - front="$(find chusql-web/.stack-work chusql-cli/.stack-work -name "$front_name" -type f 2>/dev/null | head -n 1 || true)" - fi - [ -n "$front" ] && [ -f "$front" ] || { echo "front end binary not found: $front_name" >&2; exit 1; } - cp "$front" "$stage/bin/" + for front_name in chusql-web chusql-server csql; do + front="$stack_root/bin/$front_name" + if [ ! -f "$front" ]; then + front="$(find chusql-web/.stack-work chusql-cli/.stack-work -name "$front_name" -type f 2>/dev/null | head -n 1 || true)" + fi + [ -n "$front" ] && [ -f "$front" ] || { echo "front end binary not found: $front_name" >&2; exit 1; } + cp "$front" "$stage/bin/" + done - # Haskell 一般静态链接,靶目录里真有共享库就一起带上 - for lib in chusql-storage/target/release/*.so chusql-storage/target/release/*.dylib; do - [ -f "$lib" ] && cp "$lib" "$stage/bin/" + # Haskell 一般静态链接,靶目录里真有共享库就一起带上;改名前的旧库(libchusql_storage.*)不能进包 + for lib in chusql-core/storage/target/release/*.so chusql-core/storage/target/release/*.dylib; do + [ -f "$lib" ] || continue + case "$(basename "$lib")" in + libchusql_storage.*|libchusql_storage-*) continue ;; + esac + cp "$lib" "$stage/bin/" done cp scripts/chusql.toml scripts/init.sql "$stage/scripts/" cp scripts/install.ps1 scripts/install.sh "$stage/" - if [ "$component" = web ]; then - mkdir -p "$stage/static" - cp -R chusql-web/static/. "$stage/static/" - cp scripts/csql-web.sh "$stage/" - if [ -f scripts/csql-web.ps1 ]; then cp scripts/csql-web.ps1 "$stage/"; fi - fi + mkdir -p "$stage/static" + cp -R chusql-web/static/. "$stage/static/" + cp scripts/csql-web.sh "$stage/" + if [ -f scripts/csql-web.ps1 ]; then cp scripts/csql-web.ps1 "$stage/"; fi - chmod +x "$stage/install.sh" - if [ -f "$stage/csql-web.sh" ]; then chmod +x "$stage/csql-web.sh"; fi + chmod +x "$stage/install.sh" "$stage/csql-web.sh" mkdir -p dist tar -czf "dist/$name.tar.gz" -C "$stage" . @@ -146,35 +149,35 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $component = '${{ matrix.component }}' - $name = "chusql-$component-${{ matrix.platform }}" + $name = "chusql-${{ matrix.platform }}" $stage = "dist/$name" if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/scripts" | Out-Null - Copy-Item 'chusql-storage/target/release/chusql-storage.exe' "$stage/bin/" + Copy-Item 'chusql-core/storage/target/release/chusql_core_storage.dll' "$stage/bin/" $stackRoot = (& stack path --local-install-root | Select-Object -Last 1).Trim() - $frontName = if ($component -eq 'web') { 'chusql-web.exe' } else { 'csql.exe' } - $front = Join-Path $stackRoot "bin/$frontName" - if (-not (Test-Path $front)) { - $front = Get-ChildItem -Path 'chusql-web/.stack-work', 'chusql-cli/.stack-work' -Recurse -Filter $frontName -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + foreach ($frontName in @('chusql-web.exe', 'chusql-server.exe', 'csql.exe')) { + $front = Join-Path $stackRoot "bin/$frontName" + if (-not (Test-Path $front)) { + $front = Get-ChildItem -Path 'chusql-web/.stack-work', 'chusql-cli/.stack-work' -Recurse -Filter $frontName -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + if (-not $front -or -not (Test-Path $front)) { throw "front end binary not found: $frontName" } + Copy-Item $front "$stage/bin/" } - if (-not $front -or -not (Test-Path $front)) { throw "front end binary not found: $frontName" } - Copy-Item $front "$stage/bin/" - Get-ChildItem 'chusql-storage/target/release' -Filter '*.dll' -File -ErrorAction SilentlyContinue | + # 第三方 dll 才要连带上:名字带 chusql 的是本 crate 产物或改名前的残留,上面已按名拷过 + Get-ChildItem 'chusql-core/storage/target/release' -Filter '*.dll' -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike 'chusql*' } | ForEach-Object { Copy-Item $_.FullName "$stage/bin/" } Copy-Item 'scripts/chusql.toml', 'scripts/init.sql' "$stage/scripts/" Copy-Item 'scripts/install.ps1', 'scripts/install.sh' $stage - if ($component -eq 'web') { - New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null - Copy-Item 'chusql-web/static/*' "$stage/static" -Recurse - Copy-Item 'scripts/csql-web.sh' $stage - if (Test-Path 'scripts/csql-web.ps1') { Copy-Item 'scripts/csql-web.ps1' $stage } - } + New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null + Copy-Item 'chusql-web/static/*' "$stage/static" -Recurse + Copy-Item 'scripts/csql-web.sh' $stage + if (Test-Path 'scripts/csql-web.ps1') { Copy-Item 'scripts/csql-web.ps1' $stage } New-Item -ItemType Directory -Force -Path dist | Out-Null Compress-Archive -Path "$stage/*" -DestinationPath "dist/$name.zip" -Force @@ -193,7 +196,7 @@ jobs: - name: upload uses: actions/upload-artifact@v4 with: - name: ${{ matrix.platform }}-${{ matrix.component }} + name: ${{ matrix.platform }} path: | dist/*.zip dist/*.tar.gz diff --git a/.gitignore b/.gitignore index 7a55486..c1e7d33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ # ============================================================ # ChuSQL 单仓库 .gitignore -# chusql-engine/ Haskell (Stack + Cabal) -# chusql-storage/ Rust (Cargo) +# chusql-core/engine/ Haskell (Stack + Cabal) +# chusql-core/storage/ Rust (Cargo) # chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链) # ============================================================ # ------------------------------------------------------------ -# Haskell / Stack / Cabal —— chusql-engine +# Haskell / Stack / Cabal —— chusql-core/engine # ------------------------------------------------------------ .stack-work/ dist-newstyle/ @@ -26,21 +26,24 @@ cabal.project.local cabal.project.local~ # 锁文件要入库,不要忽略: -# chusql-engine/stack.yaml.lock +# chusql-core/engine/stack.yaml.lock # cabal.project.freeze # ------------------------------------------------------------ -# Rust / Cargo —— chusql-storage +# Rust / Cargo —— chusql-core/storage # ------------------------------------------------------------ target/ **/*.rs.bk # 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库 -chusql-storage/data/ +chusql-core/storage/data/ # 一键启动脚本的默认数据目录与日志,不入库 localdata/ +# 本机打出来的发行包(scripts/package.sh / package.ps1 的产物),不入库 +releases/ + # 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库 scripts/chusql.ui.settings.json @@ -74,7 +77,7 @@ scripts/chusql.ui.settings.json \#*\# .\#* agent_credientials - +.dsh # 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl), # 需要入库,所以这里不忽略 .zed/ diff --git a/.zed/tasks.json b/.zed/tasks.json index b70113d..e2fadf3 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -2,7 +2,7 @@ { "label": "haskell: stack build", "command": "stack build", - "cwd": "$ZED_WORKTREE_ROOT/chusql-engine", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine", "reveal": "always", "hide": "on_success", "save": "all", @@ -10,7 +10,7 @@ { "label": "haskell: stack test", "command": "stack test", - "cwd": "$ZED_WORKTREE_ROOT/chusql-engine", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine", "reveal": "always", "hide": "never", "save": "all", @@ -18,7 +18,7 @@ { "label": "haskell: stack run", "command": "stack run", - "cwd": "$ZED_WORKTREE_ROOT/chusql-engine", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine", "reveal": "always", "hide": "never", "save": "all", @@ -26,7 +26,7 @@ { "label": "haskell: stack repl", "command": "stack ghci", - "cwd": "$ZED_WORKTREE_ROOT/chusql-engine", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine", "reveal": "always", "hide": "never", "save": "all", @@ -35,7 +35,7 @@ "label": "rust: cargo check", "command": "cargo", "args": ["check", "--all-targets"], - "cwd": "$ZED_WORKTREE_ROOT/chusql-storage", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/storage", "reveal": "always", "hide": "on_success", "save": "all", @@ -44,16 +44,7 @@ "label": "rust: cargo test", "command": "cargo", "args": ["test"], - "cwd": "$ZED_WORKTREE_ROOT/chusql-storage", - "reveal": "always", - "hide": "never", - "save": "all", - }, - { - "label": "rust: cargo run server", - "command": "cargo", - "args": ["run", "--bin", "server"], - "cwd": "$ZED_WORKTREE_ROOT/chusql-storage", + "cwd": "$ZED_WORKTREE_ROOT/chusql-core/storage", "reveal": "always", "hide": "never", "save": "all", diff --git a/AGENTS.md b/AGENTS.md index f17f296..5948870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,17 @@ - 索引由存储层管理,优化器只判断可点查条件。 - 优化不能改变结果语义。 +## 注释规范 +- 文件级:外部引用语句(Haskell `import`、Rust `use`、JS `import`、HTML 的 ``/` +
diff --git a/chusql-web/static/style.css b/chusql-web/static/style.css index 2ca69ab..8f6dd40 100644 --- a/chusql-web/static/style.css +++ b/chusql-web/static/style.css @@ -1,5 +1,4 @@ -/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,纯手写,无框架。 - 字体与字号由 app.js 通过 CSS 变量(--ui-font 等)按 IDE 设置覆盖。 */ +/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,变量由 app.js 覆盖。 */ :root { --bg-base: #1e1e1e; diff --git a/chusql-web/test/Gates.hs b/chusql-web/test/Gates.hs new file mode 100644 index 0000000..5619adc --- /dev/null +++ b/chusql-web/test/Gates.hs @@ -0,0 +1,269 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Gates (gateSpec) where + +import qualified Data.ByteString as BS +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import System.Directory (doesFileExist) +import System.FilePath (()) +import Test.Hspec + +-- 门禁测试:安装打包脚本、发布流水线、文档结构里必须出现的内容。 + +-- | 按 UTF-8 读取文件 +readUtf8 :: FilePath -> IO Text +readUtf8 path = TE.decodeUtf8 <$> BS.readFile path + +-- | 门禁用例入口 +gateSpec :: Spec +gateSpec = do + describe "Installer scripts (scripts/)" $ do + it "install.sh releases files, writes chusql.toml, sets PATH and removes the package" $ do + installer <- readUtf8 (".." "scripts" "install.sh") + installer `shouldSatisfy` T.isInfixOf "libchusql_core_storage" + installer `shouldSatisfy` T.isInfixOf "chusql.toml" + installer `shouldSatisfy` T.isInfixOf "PATH" + installer `shouldSatisfy` T.isInfixOf "rm -rf" + -- 一份脚本三种来源:包内(旁边有 bin/)、在线(管道进来时 $0 是 sh,按平台取 release 资源)、 + -- 显式归档或源码。去掉任何一条,从 GitHub 装的用法就断了。 + it "install.sh picks the package by platform and can fall back to source" $ do + installer <- readUtf8 (".." "scripts" "install.sh") + installer `shouldSatisfy` T.isInfixOf "bin/libchusql_core_storage" + installer `shouldSatisfy` T.isInfixOf "uname -s" + installer `shouldSatisfy` T.isInfixOf "uname -m" + installer `shouldSatisfy` T.isInfixOf "releases/latest/download" + -- 一个平台一个包(web 和 cli 都在里面那一个归档里),按平台标签找,没有组件名那一层 + installer `shouldSatisfy` T.isInfixOf "chusql-$label.$ext" + installer `shouldSatisfy` T.isInfixOf "chusql.$ext" + -- 释放按这次的选择走:没选的组件一个文件都不落地 + installer `shouldSatisfy` T.isInfixOf "cp \"$pack/bin/csql\"" + installer `shouldSatisfy` T.isInfixOf ".sha256" + installer `shouldSatisfy` T.isInfixOf "--url" + installer `shouldSatisfy` T.isInfixOf "--from-source" + installer `shouldSatisfy` T.isInfixOf "stack build --fast" + -- 版本可以指定、也可以先列出来:latest 先去 GitHub API 里挑「最新正式版且带本平台包」, + -- 自建站点(GHE / 镜像 / 本地夹具)走 /api/v3,取包也换同一套基地址。 + installer `shouldSatisfy` T.isInfixOf "--list-versions" + installer `shouldSatisfy` T.isInfixOf "api/v3" + installer `shouldSatisfy` T.isInfixOf "CHUSQL_GITHUB_BASE" + -- 版本接口未认证只有 60 次/小时,两个脚本都要能用 token 抬高限额 + installer `shouldSatisfy` T.isInfixOf "CHUSQL_GITHUB_TOKEN" + -- Windows 侧要跟 install.sh 对齐:旁边没有包就去发行站下 zip,版本可指定、可列出来, + -- 自建站点同样靠 CHUSQL_GITHUB_BASE 换基地址(api/v3 + 下载路径一起换)。 + it "install.ps1 downloads the package, picks a version and can list versions" $ do + installer <- readUtf8 (".." "scripts" "install.ps1") + installer `shouldSatisfy` T.isInfixOf "bin\\chusql_core_storage.dll" + -- 与 install.sh 同一套命名:一个平台一个包,没有组件名那一层 + installer `shouldSatisfy` T.isInfixOf "chusql-$label.zip" + installer `shouldSatisfy` T.isInfixOf "'chusql.zip'" + installer `shouldSatisfy` T.isInfixOf "releases/latest/download" + installer `shouldSatisfy` T.isInfixOf "-Version" + installer `shouldSatisfy` T.isInfixOf "-ListVersions" + installer `shouldSatisfy` T.isInfixOf ".sha256" + installer `shouldSatisfy` T.isInfixOf "api/v3" + installer `shouldSatisfy` T.isInfixOf "CHUSQL_GITHUB_BASE" + -- 版本接口未认证只有 60 次/小时,两个脚本都要能用 token 抬高限额 + installer `shouldSatisfy` T.isInfixOf "CHUSQL_GITHUB_TOKEN" + installer `shouldSatisfy` T.isInfixOf "Expand-Archive" + -- PowerShell 7 的两个坑:Content 可能是字节数组(.sha256 会变成一串数字), + -- 函数 return 数组会被折成「装数组的单个对象」(版本挑选会拿到整张 tag 表)。 + -- 两处都要绕开,否则校验和版本选择会静默出错。 + installer `shouldSatisfy` T.isInfixOf "Get-ResponseText" + installer `shouldSatisfy` T.isInfixOf "-is [byte[]]" + installer `shouldSatisfy` T.isInfixOf "ConvertFrom-Json" + -- 装什么由人当场选:逐个问、问答用英文、默认装 csql 不装 Web,选完才问管理员口令。 + -- 提问顺序固定:先 cli 后 web。两个脚本文案必须一致,改一个就得改另一个。 + it "both installers ask which components to install, in the same words" $ do + sh <- readUtf8 (".." "scripts" "install.sh") + ps <- readUtf8 (".." "scripts" "install.ps1") + let cliAsk = "Install the command line client (csql)? [Y/n]" + webAsk = "Install the web front end (browser UI)? [y/N]" + mapM_ + ( \src -> do + src `shouldSatisfy` T.isInfixOf cliAsk + src `shouldSatisfy` T.isInfixOf webAsk + src `shouldSatisfy` T.isInfixOf "Root password for" + -- cli 的问题必须出现在 web 之前(文档里的用法示例也按这个顺序写) + T.length (fst (T.breakOn cliAsk src)) `shouldSatisfy` (< T.length (fst (T.breakOn webAsk src))) + ) + [sh, ps] + sh `shouldSatisfy` T.isInfixOf "--interactive" + sh `shouldSatisfy` T.isInfixOf "both" + ps `shouldSatisfy` T.isInfixOf "-Interactive" + ps `shouldSatisfy` T.isInfixOf "both" + -- 到底装的哪个版本要打出来:latest 解析来的和显式指定的,两处都得有。 + sh `shouldSatisfy` T.isInfixOf "echo \" version $version\"" + sh `shouldSatisfy` T.isInfixOf "note \"version $version\"" + ps `shouldSatisfy` T.isInfixOf " version {0}" + -- 本机打发行包:文件名叫法和 CI 出的资产一致(install.sh / install.ps1 按同一套平台标签取), + -- 落在 releases/ 下(不入库),并自带 sha256。缺任何一样,自己打的包安装脚本就认不出来。 + it "the packaging scripts write the same asset names as the release pipeline" $ do + sh <- readUtf8 (".." "scripts" "package.sh") + ps <- readUtf8 (".." "scripts" "package.ps1") + mapM_ + ( \src -> do + src `shouldSatisfy` T.isInfixOf "releases" + src `shouldSatisfy` T.isInfixOf "install.sh" + src `shouldSatisfy` T.isInfixOf "install.ps1" + ) + [sh, ps] + sh `shouldSatisfy` T.isInfixOf "tar -czf" + sh `shouldSatisfy` T.isInfixOf "sha256" + -- 一个平台一个包:名字里不再有组件那一层,web 与 cli 都在同一个归档里, + -- 装哪个由包内脚本问;脚本自己也要按两种选择各验一遍(没选的不许落地) + sh `shouldSatisfy` T.isInfixOf "chusql-$platform" + sh `shouldSatisfy` T.isInfixOf "chusql-web/static" + sh `shouldSatisfy` T.isInfixOf "bin/csql" + sh `shouldSatisfy` T.isInfixOf "for comp in cli web" + -- 打包脚本自己不再有组件开关:--component 只留在包内安装脚本里(给非交互用), + -- 这里的自检要靠「它自己的选项表里没有组件项」来证明,不能一口咬定全文不出现这三个字。 + sh `shouldSatisfy` T.isInfixOf "try --no-build, --out-dir DIR, --platform LABEL" + sh `shouldSatisfy` (not . T.isInfixOf "--component)") + ps `shouldSatisfy` T.isInfixOf "Compress-Archive" + ps `shouldSatisfy` T.isInfixOf "Get-FileHash" + ps `shouldSatisfy` T.isInfixOf "chusql-$Platform" + ps `shouldSatisfy` T.isInfixOf "chusql-web\\static" + ps `shouldSatisfy` T.isInfixOf "bin\\csql.exe" + ps `shouldSatisfy` T.isInfixOf "Test-Installed $name 'cli'" + ps `shouldSatisfy` (not . T.isInfixOf "$Component") + + ignore <- readUtf8 (".." ".gitignore") + ignore `shouldSatisfy` T.isInfixOf "releases/" + -- static_dir 是相对路径,启动器必须先切到安装目录;少了这一句,从别处执行 + -- csql-web 就会因为找不到 static/ 直接退出(Windows 的 .ps1 靠 -WorkingDirectory)。 + it "csql-web.sh switches to the install dir before starting" $ do + launcher <- readUtf8 (".." "scripts" "csql-web.sh") + launcher `shouldSatisfy` T.isInfixOf "cd \"$home_dir\"" + launcher `shouldSatisfy` T.isInfixOf "chusql-server" + -- 默认数据目录按平台惯例算,四处必须说同一件事:Rust 代码、配置示例、模板、文档。 + -- 跟安装脚本装的位置也是一处:Windows 装到 %LOCALAPPDATA%\ChuSQL、Unix 装到 ~/.local/share/chusql, + -- 数据都放在它下面的 data/ 里。 + it "the default data dir follows the platform, and code, template and docs agree" $ do + rust <- readUtf8 (".." "chusql-core" "storage" "src" "config.rs") + rust `shouldSatisfy` T.isInfixOf "default_data_dir" + rust `shouldSatisfy` T.isInfixOf "LOCALAPPDATA" + rust `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" + rust `shouldSatisfy` (not . T.isInfixOf "DEFAULT_DATA_DIR") + + exampleFile <- readUtf8 (".." "chusql-core" "storage" "chusql-core-storage.toml.example") + -- 示例文件说自己列的值就是默认值:data_dir 不能是能生效的一行 + exampleFile `shouldSatisfy` (not . T.isInfixOf "\ndata_dir =") + exampleFile `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" + exampleFile `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" + + template <- readUtf8 (".." "scripts" "chusql.toml") + -- 模板那行是安装时被 sed / -replace 替换的占位,必须保持能生效 + template `shouldSatisfy` T.isInfixOf "\ndata_dir = " + template `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" + + configDoc <- readUtf8 (".." "doc" "config.md") + configDoc `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%\\ChuSQL\\data" + configDoc `shouldSatisfy` T.isInfixOf "chusql/data" + -- 管道传输退休后,两侧都不该再算套接字路径、也不该再认管名。 + it "neither side computes a socket path or a pipe name any more" $ do + haskellIpc <- readUtf8 (".." "chusql-core" "engine" "src" "ChuSQL" "Core" "Engine" "Storage" "IPC.hs") + haskellIpc `shouldSatisfy` (not . T.isInfixOf "XDG_RUNTIME_DIR") + haskellIpc `shouldSatisfy` (not . T.isInfixOf ".sock") + haskellIpc `shouldSatisfy` (not . T.isInfixOf "pipe") + rustModules <- readUtf8 (".." "chusql-core" "storage" "src" "lib.rs") + rustModules `shouldSatisfy` (not . T.isInfixOf "endpoint") + storageConfig <- readUtf8 (".." "chusql-core" "storage" "src" "config.rs") + storageConfig `shouldSatisfy` (not . T.isInfixOf "pipe_name") + it "the Linux pipeline builds both toolchains and runs every test suite" $ do + pipeline <- readUtf8 (".." ".github" "workflows" "linux.yml") + pipeline `shouldSatisfy` T.isInfixOf "ubuntu-latest" + pipeline `shouldSatisfy` T.isInfixOf "cargo test --release" + pipeline `shouldSatisfy` T.isInfixOf "cargo clippy" + pipeline `shouldSatisfy` T.isInfixOf "stack test --fast" + -- 打包与安装冒烟只在发版流水线里做(tag 触发 / 手动 dispatch),这条流水线不再出包 + pipeline `shouldSatisfy` (not . T.isInfixOf "package.ps1") + pipeline `shouldSatisfy` (not . T.isInfixOf "smoke-linux.sh") + -- 仓库里没有 rustfmt.toml,历史代码不是按当前 rustfmt 排的:格式化不是门禁 + pipeline `shouldSatisfy` (not . T.isInfixOf "cargo fmt") + -- 装的人不该被要求装两个工具链:发版流水线出预编译包,install.sh 按同一套平台标签去取。 + -- 流水线里装配与打归档是直接写的(两个平台各一段);本机要打同一个包用 scripts/ 下的 + -- package.sh 与它的 PowerShell 版,两边的布局必须一致。 + it "the release pipeline publishes one archive per platform, tagged the way install.sh looks it up" $ do + release <- readUtf8 (".." ".github" "workflows" "release.yml") + release `shouldSatisfy` T.isInfixOf "linux-x86_64" + release `shouldSatisfy` T.isInfixOf "macos-arm64" + release `shouldSatisfy` T.isInfixOf "macos-x86_64" + release `shouldSatisfy` T.isInfixOf "windows-x86_64" + release `shouldSatisfy` T.isInfixOf "cargo build --release" + release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-web:exe:chusql-web" + release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-cli:exe:csql" + release `shouldSatisfy` T.isInfixOf "tar -czf" + release `shouldSatisfy` T.isInfixOf "Compress-Archive" + release `shouldSatisfy` T.isInfixOf "scripts/install.sh" + release `shouldSatisfy` (not . T.isInfixOf "package.ps1") + release `shouldSatisfy` T.isInfixOf "sha256" + release `shouldSatisfy` T.isInfixOf "gh release upload" + -- 四个平台各一个包,名字里没有组件那一层:一个包里 web 与 cli 都有; + -- 矩阵里也不该再出现 web / cli 这一维(那样又会产出两个按组件命名的资产) + release `shouldSatisfy` T.isInfixOf "name: ${{ matrix.platform }}" + release `shouldSatisfy` T.isInfixOf "name=\"chusql-${{ matrix.platform }}\"" + release `shouldSatisfy` T.isInfixOf "name = \"chusql-${{ matrix.platform }}\"" + release `shouldSatisfy` (not . T.isInfixOf "matrix.component") + release `shouldSatisfy` (not . T.isInfixOf "component: [web, cli]") + it "no layer looks for a storage executable or a pipe name any more" $ do + engine <- readUtf8 (".." "chusql-core" "engine" "test" "Spec.hs") + bench <- readUtf8 (".." "benchmark" "src" "Main.hs") + template <- readUtf8 (".." "scripts" "chusql.toml") + mapM_ + (\src -> src `shouldSatisfy` (not . T.isInfixOf "pipe_name")) + [engine, bench, template] + mapM_ + (\src -> src `shouldSatisfy` (not . T.isInfixOf "chusql-storage.exe")) + [engine, bench] + -- 起存储进程那一整个模块已经删掉,别再长回来 + moduleGone <- + doesFileExist + (".." "chusql-server" "src" "ChuSQL" "Server" "StorageProcess.hs") + moduleGone `shouldBe` False + + -- 细节分到 doc/ 下四份,README 只留面向用户的关键内容;链接断了或者文档没了,等于没写。 + describe "Documentation (doc/)" $ do + it "the README keeps only the essentials and links the split documents" $ do + readme <- readUtf8 (".." "README.md") + mapM_ + (\target -> readme `shouldSatisfy` T.isInfixOf ("doc/" <> target <> ".md")) + ["install", "config", "commands", "architecture"] + -- 安装选项与配置键的长表已经搬走,不该在 README 里再长回来 + readme `shouldSatisfy` (not . T.isInfixOf "--from-source") + readme `shouldSatisfy` (not . T.isInfixOf "pool_size") + -- 仓库布局这类开发者信息属于 doc/architecture.md,不属于 README + readme `shouldSatisfy` (not . T.isInfixOf "chusql-engine/") + it "each split document covers what its name promises" $ do + let expectations = + [ ("install", ["install.sh", "install.ps1", "--component", "--install-dir", "--from-source", "--version", "--list-versions", "-Component", "-Version", "-ListVersions", "-Interactive", "CHUSQL_GITHUB_TOKEN", "rm -rf"]) + , ("config", ["[web]", "[page]", "data_dir", "host", "port"]) + , ("commands", ["csql", "--format", "\\dt", "CREATE ROLE", "GRANT", "/api/roles"]) + , ("architecture", ["chusql-core", "chusql-server", "chusql-web", "csql", "data_dir", "CREATE DATABASE"]) + ] + mapM_ + ( \(name, needles) -> do + doc <- readUtf8 (".." "doc" name <> ".md") + mapM_ (\needle -> doc `shouldSatisfy` T.isInfixOf needle) needles + ) + expectations + -- doc/ 只写面向用户的内容:构建、测试、打包这些开发流程不进文档 + it "the split documents stay user-facing (no build, test or packaging recipes)" $ do + let developerOnly = + [ "package.ps1" + , "stack build" + , "stack test" + , "cargo build" + , "cargo test" + , "cargo clippy" + , ".stack-work" + , ".github/workflows" + , "smoke-linux.sh" + ] + mapM_ + ( \name -> do + doc <- readUtf8 (".." "doc" name <> ".md") + mapM_ (\needle -> doc `shouldSatisfy` (not . T.isInfixOf needle)) developerOnly + ) + ["install", "config", "commands", "architecture"] diff --git a/chusql-web/test/Spec.hs b/chusql-web/test/Spec.hs index 34a8024..b24c937 100644 --- a/chusql-web/test/Spec.hs +++ b/chusql-web/test/Spec.hs @@ -1,116 +1,54 @@ {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} module Main (main) where -import ChuSQL.Model (ColumnType (..), Database, Table (..), Value (..), plainColumn, pattern TInt, pattern TStr, pattern TBool) -import ChuSQL.Storage.IPC (Account (..), Request (..), SchemaColumn (..), TableInfo (..), setPipeName) -import ChuSQL.Web.Accounts -import Data.Either (isLeft, isRight) -import ChuSQL.Syntax.AST (Expr (..), FromClause (..), JoinKind (..), Statement (..)) -import ChuSQL.Syntax.Parser (parseStatement) -import ChuSQL.Web.Actions ( - ColumnSpec (..), - CreateTableSpec (..), - coerceValue, - columnTypeOf, - createIndexSql, - createTableSql, - deleteRowSql, - dropIndexSql, - dropTableSql, - insertRowSql, - insertRowsSql, - isIdentifier, - selectRowsSql, - sqlLiteral, - updateRowSql, - ) -import ChuSQL.Web.API ( - AppEnv (..), - Live (..), - defaultLive, - newAppEnv, - parseCookieHeader, - readLive, - renderRow, - setLive, - webApp, - ) -import ChuSQL.Web.Auth ( - Credential (..), - SessionPolicy (..), - createSession, - defaultSessionPolicy, - deleteSession, - hashLooksValid, - hashPasswordWith, - lookupSession, - newSessionStore, - sessionPolicy, - sessionToken, - setSessionPolicy, - verifyPassword, - ) -import ChuSQL.Web.Backend (beAccounts, columnsFromStatement, inDatabase, ipcBackend, memoryBackend, tableInfoOf) -import ChuSQL.Web.Config ( - WebConfig (..), - canonicalSettingKeys, - defaultPassword, - defaultUser, - defaultWebConfig, - loadWebConfigAt, - resolveCredential, - resolvePipeName, - usingDefaultCredentials, - ) -import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit) -import ChuSQL.Web.Settings ( - SettingItem (..), +import ChuSQL.Interface.RateLimit (newRateLimiter) +import ChuSQL.Interface.Settings ( applySettings, - effectiveSettings, - findItem, - isRestartRequired, - isRootOnly, + defaultOf, + isLockedSetting, liveKeys, readSettingsFile, settingCatalogue, writeSettingsFile, ) -import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) -import ChuSQL.Web.StorageProcess (StorageProcess (..), platformBinaryName, startStorageProcess, storageChildArgs, stopStorageProcess, waitForStorage) -import ChuSQL.Web.TOML (defaultConfigFile, resolveConfigPath) -import ChuSQL.Web.UISettings ( - readUISettings, - uiSettingsFileCandidates, - validateUISettings, - writeUISettings, +import ChuSQL.Web.API ( + AppEnv (..), + Live (..), + defaultLive, + isIdentifier, + newAppEnvAt, + parseCookieHeader, + sessionCookieName, + webApp, ) -import Control.Concurrent.MVar (newMVar) -import Control.Exception (IOException, bracket, finally, try) -import Control.Monad (forM_) -import Data.Aeson (decode) +import ChuSQL.Web.Static (contentTypeOf, safeRelative) +import ChuSQL.Web.UISettings (validateUISettings) +import Control.Concurrent (forkIO) +import Control.Exception (IOException, bracket, try) +import Control.Monad (when) +import Data.Aeson (encode, object, (.=)) import qualified Data.Aeson as A import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Lazy as BL -import Data.IORef (newIORef, readIORef, writeIORef) -import Data.List (isInfixOf, isPrefixOf, nub, partition, sort) +import Data.Char (isDigit) +import Data.List (isInfixOf) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE -import Data.Time.Calendar (fromGregorian) -import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime) +import Data.Time.Clock (getCurrentTime) import Data.Unique (hashUnique, newUnique) import qualified Data.Vector as V +import Gates (gateSpec) import Network.HTTP.Client ( Manager, Request (method, requestBody, requestHeaders), - RequestBody (..), + RequestBody (RequestBodyLBS), Response (responseBody, responseHeaders, responseStatus), defaultManagerSettings, httpLbs, @@ -118,171 +56,183 @@ import Network.HTTP.Client ( parseRequest, ) import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode) -import Network.Wai (Application) +import qualified Network.Wai as Wai import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Test as WT -import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, getXdgDirectory, removePathForcibly) -import qualified System.Directory -import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv) +import System.Directory ( + createDirectoryIfMissing, + findExecutable, + getTemporaryDirectory, + removeFile, + removePathForcibly, + ) import System.Exit (ExitCode (..)) import System.FilePath (()) -import System.IO (Handle) import System.Info (os) +import System.IO (Handle, hGetLine) import System.Process ( - CreateProcess (cwd, std_err, std_out), + CreateProcess (std_err, std_out), ProcessHandle, - StdStream (..), + StdStream (CreatePipe, NoStream), createProcess, proc, - readCreateProcessWithExitCode, + terminateProcess, waitForProcess, ) import Test.Hspec -import qualified System.Process as Process import Test.Hspec.Wai hiding (pendingWith) --- ChuSQL Web 测试:纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。 +-- ChuSQL Web 测试:纯函数、静态交付、门禁,以及打到真 server 的 HTTP 与 socket 用例。 --- | 夹具库名:服务不再有默认库,测试世界里这一个库扮演"已选中的库"。 --- 它只是个普通库名(不再是保留名)——保留的只有 system。 -testDatabaseName :: String +-- | 夹具库名:测试默认选中的库 +testDatabaseName :: Text testDatabaseName = "test" --- | 夹具库的请求头 -testDatabaseHeader :: Header -testDatabaseHeader = ("X-ChuSQL-Database", TE.encodeUtf8 (T.pack testDatabaseName)) +-- | 夹具管理员 +adminUser :: Text +adminUser = "admin" --- | 没有显式指定库的请求都落到夹具库;要测"没选库"的用例请直接用 `request` -fixtureHeaders :: [Header] -> [Header] -fixtureHeaders hs - | any ((== "X-ChuSQL-Database") . fst) hs = hs - | otherwise = testDatabaseHeader : hs +-- | 夹具管理员口令 +adminPassword :: Text +adminPassword = "s3cret" --- | users 5 行 + orders 1 行 -testDb :: Database -testDb = - [ ( "users" - , Table - "users" - [("id", TInt), ("name", TStr), ("age", TInt)] - [ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (20 + i))] - | i <- [1 .. 5] - ] - ) - , ( "orders" - , Table - "orders" - [("id", TInt), ("user_id", TInt)] - [[("id", VInt 1), ("user_id", VInt 1)]] - ) - ] +-- | 换成别的库(同名的旧头去掉) +withDb :: Text -> [Header] -> [Header] +withDb name hs = ("X-ChuSQL-Database", TE.encodeUtf8 name) : filter ((/= "X-ChuSQL-Database") . fst) hs --- | 测试账号:迭代次数压低(跑得快),口令 `s3cret` -testCredential :: Credential -testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret") +-- | with 块的会话别名,状态放 Application +type TestSession a = WaiSession Wai.Application a --- | 可控时钟的起点 -epoch :: UTCTime -epoch = UTCTime (fromGregorian 2026 1 1) 0 +-- | with 的变体:把 Application 放进会话状态 +withApp :: IO Wai.Application -> SpecWith (Arg (WaiExpectation Wai.Application)) -> Spec +withApp mkApp = withState ((\app -> (app, app)) <$> mkApp) --- | 默认会话策略 -policy :: SessionPolicy -policy = SessionPolicy (8 * 3600) (24 * 3600) +-- | 纯断言抬进 WaiSession +check :: IO () -> TestSession () +check = liftIO --- | 造一条 `SELECT *` -selectStar :: FromClause -> Statement -selectStar from = Select ["*"] from Nothing [] [] Nothing +-- | 加上 JSON 内容类型头 +jsonHeaders :: [Header] -> [Header] +jsonHeaders hs = ("Content-Type", "application/json") : hs --- | 造一条显式列名的 SELECT -selectThese :: [String] -> FromClause -> Statement -selectThese cols from = Select cols from Nothing [] [] Nothing +-- | 拼出会话 Cookie 头 +cookieHeader :: Text -> Header +cookieHeader token = ("Cookie", TE.encodeUtf8 (sessionCookieName <> "=" <> token)) --- | with 块里的会话类型别名 -type TestSession a = WaiSession () a - --- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录 -freshWorld :: FilePath -> IO AppEnv -freshWorld staticDir = freshWorldWith staticDir testCredential - --- | 同上,但换一个 root 凭据(免密用例用) -freshWorldWith :: FilePath -> Credential -> IO AppEnv -freshWorldWith staticDir credential = do - db <- newMVar testDb - clock <- newIORef epoch - sessions <- newSessionStore (readIORef clock) policy - limiter <- newRateLimiter (readIORef clock) 5 (5 * 60) - env <- newAppEnv (memoryBackend testDatabaseName db) sessions credential limiter staticDir - path <- tempSettingsPath "default" - removeIfExists path - uiPath <- tempUISettingsPath "default" - removeIfExists uiPath - pure env{aeSettingsFile = path, aeUISettingsFile = uiPath, aeEffective = Map.empty} - --- | 免密管理员的世界:空口令的凭据等价于"只允许管理员免密登录" -withPasswordlessWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec -withPasswordlessWorld staticDir body = do - appEnv <- runIO (freshWorldWith staticDir (Credential "admin" "")) - with (webApp appEnv) (body appEnv) - --- | 临时设置文件(每个用例组一个名字,互不干扰) -tempSettingsPath :: String -> IO FilePath -tempSettingsPath name = do - tmp <- getTemporaryDirectory - pure (tmp ("chusql-web-test-" ++ name ++ "-settings.json")) - --- | 临时 IDE 设置文件(同样按用例组取名) -tempUISettingsPath :: String -> IO FilePath -tempUISettingsPath name = do - tmp <- getTemporaryDirectory - pure (tmp ("chusql-web-test-" ++ name ++ "-ui-settings.json")) - --- | 文件不存在也算成功 -removeIfExists :: FilePath -> IO () -removeIfExists path = do - there <- doesFileExist path - if there then removeFileSafe path else pure () +-- | 发独立会话的 WAI 请求,不共享 cookie jar +requestAs :: Method -> BS.ByteString -> [Header] -> BL.ByteString -> TestSession WT.SResponse +requestAs verb path hs payload = do + app <- getState + liftIO $ WT.runSession send app where - removeFileSafe p = do - _ <- try (System.Directory.removeFile p) :: IO (Either IOException ()) - pure () + -- | 清空 cookie jar 后发出请求 + send = do + WT.modifyClientCookies (const Map.empty) + WT.srequest $ + WT.SRequest + ( WT.setPath + ( WT.defaultRequest + { Wai.requestMethod = verb + , Wai.requestHeaders = hs + , Wai.requestBodyLength = Wai.KnownLength (fromIntegral (BL.length payload)) + } + ) + path + ) + payload --- | 带自己的设置文件的用例组 -freshWorldAt :: FilePath -> String -> IO AppEnv -freshWorldAt staticDir name = do - env <- freshWorld staticDir - path <- tempSettingsPath name - removeIfExists path - uiPath <- tempUISettingsPath name - removeIfExists uiPath - pure env{aeSettingsFile = path, aeUISettingsFile = uiPath} +-- | 带头发 GET 请求 +getAs :: [Header] -> BS.ByteString -> TestSession WT.SResponse +getAs hs path = requestAs methodGet path hs BL.empty --- | 临时静态目录,内容固定便于断言 -makeStaticDir :: IO FilePath -makeStaticDir = do - tmp <- getTemporaryDirectory - let dir = tmp "chusql-web-static-test" - createDirectoryIfMissing True dir - BS.writeFile (dir "index.html") "ChuSQL Console" - BS.writeFile (dir "app.js") "// test bundle\n" - BS.writeFile (dir "style.css") "body{}\n" - BS.writeFile (dir "secret.token") "do-not-serve\n" - pure dir +-- | 带 JSON 头发 POST 请求 +postAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +postAs hs path payload = requestAs methodPost path (jsonHeaders hs) payload --- | 一个用例组一套环境 -withWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec -withWorld staticDir body = do - appEnv <- runIO (freshWorld staticDir) - with (webApp appEnv) (body appEnv) +-- | 带 JSON 头发 PUT 请求 +putAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +putAs hs path payload = requestAs "PUT" path (jsonHeaders hs) payload --- | 同上,但带着自己的设置文件(改设置/改口令的用例不互相串) -withWorldAt :: FilePath -> String -> (AppEnv -> SpecWith ((), Application)) -> Spec -withWorldAt staticDir name body = do - appEnv <- runIO (freshWorldAt staticDir name) - with (webApp appEnv) (body appEnv) +-- | 带 JSON 头发 PATCH 请求 +patchAs :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +patchAs hs path payload = requestAs "PATCH" path (jsonHeaders hs) payload --- | 响应体解成 JSON +-- | 带头发 DELETE 请求 +deleteAs :: [Header] -> BS.ByteString -> TestSession WT.SResponse +deleteAs hs path = requestAs "DELETE" path hs BL.empty + +-- | 用账号口令登录 +loginAs :: Text -> Text -> TestSession WT.SResponse +loginAs user password = + requestAs + methodPost + "/api/login" + (jsonHeaders []) + (encode (object ["user" .= user, "password" .= password])) + +-- | 登录并返回会话 Cookie 请求头 +signIn :: Text -> Text -> TestSession [Header] +signIn user password = do + res <- loginAs user password + expectStatusWith "sign-in" res 204 + case sessionCookieOf res of + Just token -> pure [cookieHeader token] + Nothing -> do + check (expectationFailure "sign-in succeeded without a session cookie") + pure [] + +-- | 以管理员登录并取请求头 +adminHeaders :: TestSession [Header] +adminHeaders = signIn adminUser adminPassword + +-- | 已登录的普通账号 +ordinaryHeaders :: Text -> Text -> TestSession [Header] +ordinaryHeaders user password = do + res <- loginAs user password + expectStatusWith "ordinary sign-in" res 204 + case sessionCookieOf res of + Just token -> pure [cookieHeader token] + Nothing -> do + check (expectationFailure "ordinary sign-in without a session cookie") + pure [] + +-- | 建库,已存在就算就位 +ensureDatabase :: [Header] -> Text -> TestSession () +ensureDatabase hs name = do + listed <- getAs hs "/api/databases" + expectStatusWith "list databases" listed 200 + let names = map asText (items (jsonBody listed)) + when (name `notElem` names) $ do + created <- postAs hs "/api/databases" (encode (object ["name" .= name])) + expectStatusWith ("create database " <> T.unpack name) created 200 + +-- | 建表:已存在就当就位 +ensureTable :: [Header] -> Text -> BL.ByteString -> TestSession () +ensureTable hs name body = do + listed <- getAs (withDb testDatabaseName hs) "/api/tables" + expectStatusWith "list tables" listed 200 + let names = map (asText . at "table") (items (jsonBody listed)) + when (name `notElem` names) $ do + created <- postAs (withDb testDatabaseName hs) "/api/tables" body + expectStatusWith ("create table " <> T.unpack name) created 200 + +-- | 建账号:重名(409)也算就位 +ensureAccount :: [Header] -> Text -> Text -> TestSession () +ensureAccount hs user password = do + created <- + postAs + (withDb "system" hs) + "/api/tables/__chusql_users/rows" + (encode (object ["values" .= object ["user" .= user, "password" .= password]])) + check (statusCode (WT.simpleStatus created) `shouldSatisfy` (`elem` [200, 409])) + +-- | 列定义 +column :: Text -> Text -> A.Value +column name ty = object ["name" .= name, "type" .= ty] + +-- | 响应体重解成 JSON jsonBody :: WT.SResponse -> A.Value -jsonBody res = fromMaybe A.Null (decode (WT.simpleBody res)) +jsonBody res = fromMaybe A.Null (A.decode (WT.simpleBody res)) -- | 取字段 field :: Text -> A.Value -> Maybe A.Value @@ -308,79 +258,37 @@ asInt :: A.Value -> Int asInt (A.Number n) = round n asInt _ = -1 --- | 第 i 个元素(越界给 Nothing,不用 `!!`) +-- | 第 i 个元素,越界给 Nothing nth :: Int -> [a] -> Maybe a nth i xs = case drop i xs of (x : _) -> Just x [] -> Nothing --- | 取 Set-Cookie 里的会话令牌 -sessionCookieOf :: WT.SResponse -> Maybe Text -sessionCookieOf res = do - raw <- lookup "Set-Cookie" (WT.simpleHeaders res) - parseCookieHeader "chusql_session" (TE.decodeUtf8 raw) +-- | 数组字段的长度 +arrayLen :: A.Value -> Int +arrayLen = length . items --- | 会话令牌变请求头;顺带带上夹具库——服务没有默认库,缺头就是"没选库" -cookieHeaders :: WT.SResponse -> [Header] -cookieHeaders res = case sessionCookieOf res of - Nothing -> [] - Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token)), testDatabaseHeader] +-- | 第一行(位置化数组) +firstRow :: WT.SResponse -> [A.Value] +firstRow res = case items (at "rows" (jsonBody res)) of + (A.Array xs : _) -> V.toList xs + _ -> [] --- | 把请求指到系统库 system(账号表与 system 库本身都在那里) -systemHeaders :: [Header] -> [Header] -systemHeaders hs = ("X-ChuSQL-Database", "system") : filter ((/= "X-ChuSQL-Database") . fst) hs - --- | 带 JSON 体的请求 -jsonRequest :: Method -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse -jsonRequest httpMethod urlPath payload = - request httpMethod urlPath [("Content-Type", "application/json")] payload - --- | 登录请求体 -loginBody :: Text -> Text -> BL.ByteString -loginBody user password = - BL.fromStrict . TE.encodeUtf8 $ - T.concat ["{\"user\":\"", user, "\",\"password\":\"", password, "\"}"] - --- | 登录 -loginAs :: Text -> Text -> TestSession WT.SResponse -loginAs user password = jsonRequest methodPost "/api/login" (loginBody user password) - --- | 带请求头的 GET -getWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse -getWith headers path = request methodGet path (fixtureHeaders headers) "" - --- | 带请求头的 JSON POST -postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse -postWith headers path payload = - request methodPost path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload - --- | 带请求头的 JSON PUT(改设置用的是 PUT) -putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse -putWith headers path payload = - request "PUT" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload - --- | 带请求头的 JSON PATCH(改行用的是 PATCH) -patchWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse -patchWith headers path payload = - request "PATCH" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload - --- | 带请求头的 DELETE -deleteWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse -deleteWith headers path = request "DELETE" path (fixtureHeaders headers) "" - --- | 在 items 数组里按键找一项(找不到给 Null) +-- | items 数组里按键找一项(找不到给 Null) headEntry :: Text -> [A.Value] -> A.Value headEntry key entries = case [e | e <- entries, asText (at "key" e) == key] of (e : _) -> e [] -> A.Null --- | 纯断言抬进 WaiSession -check :: IO () -> TestSession () -check = liftIO +-- | 响应头 +responseHeader :: Header -> WT.SResponse -> Maybe BS.ByteString +responseHeader (name, _) res = lookup name (WT.simpleHeaders res) --- | 已经拿到响应对象时的状态码断言 -hasStatus :: WT.SResponse -> Int -> IO () -hasStatus res code = statusCode (WT.simpleStatus res) `shouldBe` code +-- | 取 Set-Cookie 里的会话令牌 +sessionCookieOf :: WT.SResponse -> Maybe Text +sessionCookieOf res = do + raw <- lookup "Set-Cookie" (WT.simpleHeaders res) + parseCookieHeader sessionCookieName (TE.decodeUtf8 raw) -- | 状态码断言,失败时把响应体和状态码一起报出来(定位用) expectStatusWith :: String -> WT.SResponse -> Int -> TestSession () @@ -399,2319 +307,635 @@ expectStatusWith label res code = ++ show (WT.simpleBody res) ) --- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉) -withEnv :: [(String, String)] -> IO a -> IO a -withEnv kvs act = do - saved <- mapM (\(k, _) -> fmap ((,) k) (lookupEnv k)) kvs - mapM_ (uncurry setEnv) kvs - act `finally` mapM_ restore saved - where - restore (k, mv) = maybe (unsetEnv k) (setEnv k) mv - --- | 按 UTF-8 读文件(避免解码错) -readUtf8 :: FilePath -> IO Text -readUtf8 path = TE.decodeUtf8 <$> BS.readFile path - --- | Either 判空 -isLeftE :: Either a b -> Bool -isLeftE (Left _) = True -isLeftE (Right _) = False - -- | Either 判成 isRightE :: Either a b -> Bool isRightE = either (const False) (const True) --- | 响应体里第一行(表格数据) -firstRow :: WT.SResponse -> [A.Value] -firstRow res = case items (at "rows" (jsonBody res)) of - (r : _) -> case r of - A.Array xs -> V.toList xs - _ -> [] - [] -> [] - --- | 数组字段的长度 -arrayLen :: A.Value -> Int -arrayLen = length . items - -main :: IO () -main = hspec spec - -spec :: Spec -spec = do - staticDir <- runIO makeStaticDir - unitSpec - actionsSpec - staticSpec staticDir - staticAssetSpec - staticServeSpec - authSpec staticDir - passwordlessSpec staticDir - catalogSpec staticDir - limitsSpec staticDir - oneClickSpec staticDir - sqlSpec staticDir - privilegesSpec staticDir - settingsSpec staticDir - uiSettingsSpec staticDir - sortSpec staticDir - securitySpec staticDir - e2eSpec staticDir - -unitSpec :: Spec -unitSpec = do - describe "Password hashing" $ do - it "accepts the correct password" $ - verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "s3cret" `shouldBe` True - it "rejects a wrong password" $ - verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "wrong" `shouldBe` False - it "treats a broken encoded hash as mismatch instead of throwing" $ - verifyPassword "garbage" "s3cret" `shouldBe` False - - describe "Sessions" $ do - it "token is 64 hex chars and differs between calls" $ do - a <- sessionToken - b <- sessionToken - T.length a `shouldBe` 64 - T.all (`elem` ("0123456789abcdef" :: String)) a `shouldBe` True - a `shouldNotBe` b - it "idle timeout drops the session" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) policy - token <- createSession store "admin" - fresh <- lookupSession store token - fresh `shouldBe` Just "admin" - writeIORef clock (addUTCTime (9 * 3600) epoch) - stale <- lookupSession store token - stale `shouldBe` Nothing - it "activity refreshes the idle timer but the absolute limit still expires" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) policy - token <- createSession store "admin" - writeIORef clock (addUTCTime (7 * 3600) epoch) - seen <- lookupSession store token - seen `shouldBe` Just "admin" - writeIORef clock (addUTCTime (13 * 3600) epoch) - seenAgain <- lookupSession store token - seenAgain `shouldBe` Just "admin" - writeIORef clock (addUTCTime (25 * 3600) epoch) - gone <- lookupSession store token - gone `shouldBe` Nothing - it "deleted session is no longer found" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) policy - token <- createSession store "admin" - deleteSession store token - found <- lookupSession store token - found `shouldBe` Nothing - it "a new expiry policy takes effect immediately (settings hot reload path)" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) (SessionPolicy (8 * 3600) (24 * 3600)) - token <- createSession store "admin" - setSessionPolicy store (SessionPolicy 10 20) - now <- sessionPolicy store - spIdleSeconds now `shouldBe` 10 - writeIORef clock (addUTCTime 11 epoch) - gone <- lookupSession store token - gone `shouldBe` Nothing - - describe "Account policy and lifecycle" $ do - it "rejects invalid configurable policy limits" $ do - mapM_ (\(key, value) -> applySettings Map.empty (Map.singleton key value) `shouldSatisfy` isLeft) - [("password-min-length", "7"), ("password-classes", "5")] - it "the admin is configuration only: no storage needed to sign in" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - let backend = (memoryBackend testDatabaseName db){beAccounts = const (pure (Left "unavailable"))} - service <- newAccounts backend sessions testCredential - authenticate service "admin" "s3cret" >>= (`shouldSatisfy` isRight) - listAccounts service >>= (`shouldSatisfy` isLeft) - it "enforces password length and character classes" $ do - let pol = defaultPasswordPolicy - passwordAllowed pol "tiny" `shouldSatisfy` isLeft - passwordAllowed pol "alllowercaseletters" `shouldSatisfy` isLeft - passwordAllowed pol "long-password" `shouldBe` Right () - it "the admin name is reserved and ordinary names are validated" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential - runAccountCommand service (Root "admin") (CreateAccount "ADMIN" "another-password") >>= (`shouldSatisfy` isLeft) - runAccountCommand service (Root "admin") (CreateAccount "with space" "another-password") >>= (`shouldSatisfy` isLeft) - runAccountCommand service (Root "admin") (CreateAccount "alice" "tiny") >>= (`shouldSatisfy` isLeft) - it "creates, resets and drops ordinary accounts, and a reset revokes their sessions" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential - let admin = Root "admin" - runAccountCommand service admin (CreateAccount "Alice" "alice-password") >>= (`shouldBe` Right ()) - Right alice <- authenticate service "alice" "alice-password" - Right bob <- authenticate service "admin" "s3cret" - runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldBe` Right ()) - currentPrincipal service alice >>= (`shouldSatisfy` isLeft) - currentPrincipal service bob >>= (`shouldSatisfy` isRight) - authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isLeft) - authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isRight) - runAccountCommand service admin (DropAccount "alice") >>= (`shouldBe` Right ()) - authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft) - runAccountCommand service admin (DropAccount "alice") >>= (`shouldSatisfy` isLeft) - it "an ordinary account cannot administer accounts" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential - let admin = Root "admin" - runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ()) - Right token <- authenticate service "alice" "alice-password" - Right principal <- currentPrincipal service token - principalIsRoot principal `shouldBe` False - principalName principal `shouldBe` "alice" - runAccountCommand service principal (CreateAccount "bob" "bob-password") >>= (`shouldSatisfy` isLeft) - runAccountCommand service principal (DropAccount "alice") >>= (`shouldSatisfy` isLeft) - authenticate service "bob" "bob-password" >>= (`shouldSatisfy` isLeft) - it "a failed account write keeps the old password" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - let original = memoryBackend testDatabaseName db - backend = original{beAccounts = \req -> case req of - ReqAccountReset {} -> pure (Left "write failed") - _ -> beAccounts original req} - service <- newAccounts backend sessions testCredential - let admin = Root "admin" - runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ()) - runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldSatisfy` isLeft) - authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isRight) - authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft) - it "a legacy row named like the administrator can be dropped, but not recreated" $ do - db <- newMVar testDb - sessions <- newSessionStore (pure epoch) policy - _ <- beAccounts (memoryBackend testDatabaseName db) (ReqAccountCreate "admin" "legacy-hash") - service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential - runAccountCommand service (Root "admin") (CreateAccount "admin" "another-password") >>= (`shouldSatisfy` isLeft) - runAccountCommand service (Root "admin") (ResetAccountPassword "admin" "another-password") >>= (`shouldSatisfy` isLeft) - runAccountCommand service (Root "admin") (DropAccount "admin") >>= (`shouldBe` Right ()) - - describe "Login rate limiting" $ do - it "below the limit is not blocked" $ do - clock <- newIORef epoch - rl <- newRateLimiter (readIORef clock) 3 60 - rateLimitRecord rl "admin" - rateLimitRecord rl "admin" - open <- rateLimitBlock rl "admin" - open `shouldBe` Nothing - it "hitting the limit blocks and returns seconds to wait" $ do - clock <- newIORef epoch - rl <- newRateLimiter (readIORef clock) 3 60 - mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] - blocked <- rateLimitBlock rl "admin" - blocked `shouldSatisfy` maybe False (> 0) - it "window expiry allows retries again" $ do - clock <- newIORef epoch - rl <- newRateLimiter (readIORef clock) 3 60 - mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] - writeIORef clock (addUTCTime 61 epoch) - open <- rateLimitBlock rl "admin" - open `shouldBe` Nothing - it "raising the threshold unlocks immediately (settings hot reload)" $ do - clock <- newIORef epoch - rl <- newRateLimiter (readIORef clock) 3 60 - mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] - blocked <- rateLimitBlock rl "admin" - blocked `shouldSatisfy` maybe False (> 0) - setRateLimit rl 10 60 - unlocked <- rateLimitBlock rl "admin" - unlocked `shouldBe` Nothing - - describe "Cookie parsing" $ do - it "picks the session token out of many cookies" $ - parseCookieHeader "chusql_session" "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123" - it "empty value does not count as a value" $ - parseCookieHeader "chusql_session" "chusql_session=" `shouldBe` Nothing - - describe "Password hash validation (checked before changing the password)" $ do - it "self-computed hash is valid" $ - hashLooksValid (hashPasswordWith 1000 (BS.replicate 16 5) "s3cret") `shouldBe` True - it "broken hashes are invalid (bad length/algo/parts)" $ do - hashLooksValid "garbage" `shouldBe` False - hashLooksValid "pbkdf2-sha256$1000$00$00" `shouldBe` False - hashLooksValid "md5$1000$00$00" `shouldBe` False - - describe "Settings catalogue (shared by the settings page and the server)" $ do - it "keys are unique and kind/default are filled" $ do - let keys = map siKey settingCatalogue - length keys `shouldBe` length (nub keys) - all (not . T.null . siKey) settingCatalogue `shouldBe` True - all (not . T.null . siLabel) settingCatalogue `shouldBe` True - all (\i -> siKind i `elem` ["text", "int", "bool", "secret"]) settingCatalogue `shouldBe` True - let machineReadable = [i | i <- settingCatalogue, siKind i `elem` ["int", "bool"]] - machineReadable `shouldSatisfy` (not . null) - all (not . T.null . siDefault) machineReadable `shouldBe` True - [siDefault i | i <- settingCatalogue, siKind i == "secret"] `shouldSatisfy` all T.null - let pairs = [(siSection i, siTomlKey i) | i <- settingCatalogue] - [s | (s, _) <- pairs] `shouldSatisfy` all (not . T.null) - [k | (_, k) <- pairs] `shouldSatisfy` all (not . T.null) - length pairs `shouldBe` length (nub pairs) - findItem "pipe-name" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("server", "pipe_name")) - findItem "storage-page-size" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("page", "size")) - it "critical settings are root-only, tuning ones are not" $ do - isRootOnly "port" `shouldBe` True - isRootOnly "host" `shouldBe` True - isRootOnly "user" `shouldBe` True - isRootOnly "password" `shouldBe` True - isRootOnly "rows-per-page" `shouldBe` False - isRootOnly "body-limit" `shouldBe` False - it "live keys exist in the catalogue and are all integers" $ do - liveKeys `shouldNotSatisfy` null - map findItem liveKeys `shouldSatisfy` all (/= Nothing) - [siKind i | Just i <- map findItem liveKeys] `shouldSatisfy` all (== "int") - it "restart and live flags do not conflict: port needs a restart, page size does not" $ do - isRestartRequired "port" `shouldBe` True - isRestartRequired "static-dir" `shouldBe` True - isRestartRequired "rows-per-page" `shouldBe` False - filter isRestartRequired liveKeys `shouldBe` [] - it "the flattened startup config carries the real port/account" $ do - let flat = effectiveSettings defaultWebConfig - Map.lookup "port" flat `shouldBe` Just "7777" - Map.lookup "user" flat `shouldBe` Just defaultUser - Map.lookup "rows-per-page" flat `shouldBe` Just "25" - Map.member "password" flat `shouldBe` False - Map.member "password-hash" flat `shouldBe` False - it "merge settings: known keys applied, unknown rejected, empty clears" $ do - let merged = applySettings Map.empty (Map.fromList [("rows-per-page", "50"), ("port", "9000")]) - fmap (Map.lookup "rows-per-page") merged `shouldBe` Right (Just "50") - applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE - fmap (Map.lookup "port") (applySettings (Map.fromList [("port", "9000")]) (Map.fromList [("port", "")])) - `shouldBe` Right (Just "") - it "type validation: ints reject letters, bools accept only known spellings" $ do - applySettings Map.empty (Map.fromList [("rows-per-page", "abc")]) `shouldSatisfy` isLeftE - applySettings Map.empty (Map.fromList [("rows-per-page", "10")]) `shouldSatisfy` isRightE - applySettings Map.empty (Map.fromList [("seed", "maybe")]) `shouldSatisfy` isLeftE - applySettings Map.empty (Map.fromList [("seed", "on")]) `shouldSatisfy` isRightE - it "write settings file: empty values are not stored and reads come back" $ do - tmp <- getTemporaryDirectory - let path = tmp "chusql-web-test-write-settings.json" - removeIfExists path - written <- writeSettingsFile path (Map.fromList [("port", "9000"), ("host", "")]) - written `shouldBe` Right () - stored <- readSettingsFile path - Map.lookup "port" stored `shouldBe` Just "9000" - Map.member "host" stored `shouldBe` False - it "write settings file: reports failure when the directory cannot be created" $ do - tmp <- getTemporaryDirectory - let blocker = tmp "chusql-web-test-blocker" - removeIfExists blocker - BS.writeFile blocker "not a directory" - failed <- writeSettingsFile (blocker "settings.json") (Map.fromList [("port", "1")]) - failed `shouldSatisfy` isLeftE - removeIfExists blocker - it "an unreadable file counts as empty config (not a crash)" $ do - tmp <- getTemporaryDirectory - missing <- readSettingsFile (tmp "chusql-web-test-does-not-exist.json") - missing `shouldBe` Map.empty - it "the config file lives at the fixed place the installer writes" $ do - fixed <- defaultConfigFile - dir <- getXdgDirectory XdgConfig "ChuSQL" - fixed `shouldBe` (dir "chusql.toml") - it "an explicit --config path wins over the fixed place" $ do - tmp <- getTemporaryDirectory - let explicit = tmp "chusql-web-test-explicit.toml" - resolveConfigPath (Just explicit) `shouldReturn` explicit - it "without --config the fixed place is used" $ do - fixed <- defaultConfigFile - resolveConfigPath Nothing `shouldReturn` fixed - - describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do - it "column and direction produce ORDER BY" $ do - selectRowsSql "users" (Just ("age", True)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age ASC" - selectRowsSql "users" (Just ("age", False)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age DESC" - it "equality filters become WHERE with AND, placed before ORDER BY" $ do - selectRowsSql "users" Nothing [("name", VStr "Ann"), ("age", VInt 30)] - `shouldBe` Right "SELECT * FROM users WHERE name = 'Ann' AND age = 30" - selectRowsSql "users" (Just ("age", False)) [("name", VStr "O'Brien")] - `shouldBe` Right "SELECT * FROM users WHERE name = 'O''Brien' ORDER BY age DESC" - selectRowsSql "users" Nothing [("vip", VBool True)] - `shouldBe` Right "SELECT * FROM users WHERE vip = TRUE" - it "table and column names go through the whitelist (including filter columns)" $ do - selectRowsSql "users; DROP TABLE users" Nothing [] `shouldSatisfy` isLeftE - selectRowsSql "users" (Just ("age; DROP TABLE users", True)) [] `shouldSatisfy` isLeftE - selectRowsSql "users" Nothing [("age; DROP TABLE users", VInt 1)] `shouldSatisfy` isLeftE - - describe "Identifier whitelist" $ do - it "normal table names pass" $ do - isIdentifier "users" `shouldBe` True - isIdentifier "user_1" `shouldBe` True - isIdentifier "_tmp" `shouldBe` True - it "leading digit / space / semicolon / empty string are rejected" $ do - isIdentifier "1users" `shouldBe` False - isIdentifier "user name" `shouldBe` False - isIdentifier "users;DROP TABLE users" `shouldBe` False - isIdentifier "" `shouldBe` False - it "over 64 characters is rejected" $ - isIdentifier (T.replicate 65 "a") `shouldBe` False - - describe "Static file name whitelist" $ do - it "normal file names pass" $ do - safeRelative "app.js" `shouldBe` Just "app.js" - safeRelative "style.css" `shouldBe` Just "style.css" - safeRelative "codicon.ttf" `shouldBe` Just "codicon.ttf" - safeRelative "editor.worker-a1.js" `shouldBe` Just "editor.worker-a1.js" - it "traversal / subdirectory / empty string are all rejected" $ do - safeRelative "../secret" `shouldBe` Nothing - safeRelative "sub/app.js" `shouldBe` Nothing - safeRelative "a\\b" `shouldBe` Nothing - safeRelative "" `shouldBe` Nothing - safeRelative "a b.js" `shouldBe` Nothing - it "disallowed extensions fail (.token / .db / no extension)" $ do - safeRelative "secret.token" `shouldBe` Nothing - safeRelative "catalog.json" `shouldBe` Nothing - safeRelative "data.db" `shouldBe` Nothing - safeRelative "README" `shouldBe` Nothing - - describe "Content-Type" $ do - it "chosen by extension (case-insensitive)" $ do - contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8" - contentTypeOf "app.JS" `shouldBe` "text/javascript; charset=utf-8" - contentTypeOf "style.css" `shouldBe` "text/css; charset=utf-8" - contentTypeOf "codicon.ttf" `shouldBe` "font/ttf" - contentTypeOf "x.bin" `shouldBe` "application/octet-stream" - - describe "Column inference (headers even for empty results)" $ do - it "normalizes default qualified headers" $ do - fmap (columnsFromStatement testDb) (parseStatement "SELECT users.name FROM users WHERE FALSE") `shouldBe` Right ["name"] - it "infers headers for a SELECT without FROM" $ do - fmap (columnsFromStatement testDb) (parseStatement "SELECT 1 + 2 LIMIT 0") `shouldBe` Right ["1 + 2"] - it "SELECT * expands to the table columns" $ - columnsFromStatement testDb (selectStar (FromTable Nothing "users")) - `shouldBe` ["id", "name", "age"] - it "aliased SELECT * prefixes the alias" $ - columnsFromStatement testDb (selectStar (FromTable (Just "u") "users")) - `shouldBe` ["u.id", "u.name", "u.age"] - it "JOIN SELECT * puts the left table first" $ - columnsFromStatement - testDb - (selectStar (FromJoin InnerJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "users.id") (Col "o.user_id")))) - `shouldBe` ["users.id", "users.name", "users.age", "o.id", "o.user_id"] - it "explicit column names are kept as-is" $ - columnsFromStatement testDb (selectThese ["name"] (FromTable Nothing "users")) `shouldBe` ["name"] - it "write statements have no result columns" $ - columnsFromStatement testDb (DropTable "users") `shouldBe` [] - - describe "Row rendering" $ do - it "values follow the given column order, missing columns give null" $ - renderRow ["name", "id", "age"] [("id", VInt 1), ("name", VStr "a")] - `shouldBe` [A.String "a", A.Number 1, A.Null] - - describe "In-memory backend catalogue" $ do - it "row count, columns and stats all match" $ do - case lookup "users" testDb of - Nothing -> expectationFailure "testDb has no users" - Just tbl -> do - let info = tableInfoOf ("users", tbl) - tiTable info `shouldBe` "users" - tiRows info `shouldBe` 5 - map scName (tiColumns info) `shouldBe` ["id", "name", "age"] - tiStats info `shouldBe` [("id", 5, False), ("name", 5, False), ("age", 5, False)] - - describe "Database qualification (inDatabase)" $ do - let rewrite db sql = parseStatement sql >>= Right . inDatabase db - - it "prefixes the tables of a subquery as well" $ do - case rewrite "sales" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of - Left err -> expectationFailure err - Right stmt -> do - let text = show stmt - ("sales.users" `isInfixOf` text) `shouldBe` True - ("sales.orders" `isInfixOf` text) `shouldBe` True - ("\"orders\"" `isInfixOf` text) `shouldBe` False - - it "keeps the JOIN kind while prefixing both sides" $ do - case rewrite "sales" "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id" of - Left err -> expectationFailure err - Right stmt -> do - let text = show stmt - ("LeftJoin" `isInfixOf` text) `shouldBe` True - ("sales.users" `isInfixOf` text) `shouldBe` True - ("sales.orders" `isInfixOf` text) `shouldBe` True - ("\"orders\"" `isInfixOf` text) `shouldBe` False - - it "qualifies a subquery that sits inside a JOIN condition" $ do - case rewrite "sales" "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id AND EXISTS (SELECT 1 FROM orders x WHERE x.user_id = u.id)" of - Left err -> expectationFailure err - Right stmt -> do - let text = show stmt - ("sales.users" `isInfixOf` text) `shouldBe` True - ("sales.orders" `isInfixOf` text) `shouldBe` True - ("\"orders\"" `isInfixOf` text) `shouldBe` False - - it "leaves table names alone when no database is selected" $ do - case rewrite "" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of - Left err -> expectationFailure err - Right stmt -> do - let text = show stmt - ("\"users\"" `isInfixOf` text) `shouldBe` True - ("\"orders\"" `isInfixOf` text) `shouldBe` True - ("test." `isInfixOf` text) `shouldBe` False - - describe "Administrator credential source (settings file only)" $ do - it "with nothing configured the built-in demo account is used" $ do - defaultUser `shouldBe` "root" - usingDefaultCredentials Map.empty `shouldBe` True - cred <- resolveCredential defaultWebConfig Map.empty - credUser cred `shouldBe` defaultUser - verifyPassword (credEncoded cred) defaultPassword `shouldBe` True - it "a plain password in the settings file wins" $ do - let saved = Map.singleton "password" "s3cret" - usingDefaultCredentials saved `shouldBe` False - cred <- resolveCredential defaultWebConfig saved - verifyPassword (credEncoded cred) "s3cret" `shouldBe` True - verifyPassword (credEncoded cred) defaultPassword `shouldBe` False - it "an explicitly empty password makes the administrator passwordless" $ do - let saved = Map.singleton "password" "" - usingDefaultCredentials saved `shouldBe` False - cred <- resolveCredential defaultWebConfig saved - credUser cred `shouldBe` defaultUser - credEncoded cred `shouldBe` "" - -- 空口令不是"能匹配任意口令"的哈希:它是一个明确的空编码串 - verifyPassword (credEncoded cred) "" `shouldBe` False - it "the retired password-hash key is not a setting and never a credential" $ do - let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash" - cred <- resolveCredential defaultWebConfig (Map.singleton "password-hash" encoded) - verifyPassword (credEncoded cred) defaultPassword `shouldBe` True - applySettings Map.empty (Map.singleton "password-hash" encoded) `shouldSatisfy` isLeftE - it "the administrator name comes from the file, and --user still wins" $ do - cred <- resolveCredential defaultWebConfig (Map.fromList [("user", "tester"), ("password", "s3cret")]) - credUser cred `shouldBe` "tester" - fromCli <- resolveCredential defaultWebConfig{wcUser = "from-cli"} (Map.singleton "user" "tester") - credUser fromCli `shouldBe` "from-cli" - it "the password environment variables are no longer consulted" $ do - tmp <- getTemporaryDirectory - let missing = tmp "chusql-web-test-cred-env.toml" - removeIfExists missing - withEnv [("CHUSQL_WEB_PASSWORD", "from-env"), ("CHUSQL_WEB_USER", "env-user")] $ do - cfg <- loadWebConfigAt missing - wcUser cfg `shouldBe` "" - cred <- resolveCredential cfg Map.empty - credUser cred `shouldBe` defaultUser - verifyPassword (credEncoded cred) "from-env" `shouldBe` False - - describe "Config: built-in defaults" $ do - it "port defaults to 7777 and the administrator name is not baked into the config" $ do - wcPort defaultWebConfig `shouldBe` 7777 - wcHost defaultWebConfig `shouldBe` "127.0.0.1" - wcUser defaultWebConfig `shouldBe` "" - wcCookieSecure defaultWebConfig `shouldBe` False - it "session / rate limit / paging / row limits all have explicit defaults" $ do - wcSessionIdle defaultWebConfig `shouldBe` 8 * 3600 - wcSessionMax defaultWebConfig `shouldBe` 24 * 3600 - wcLoginMaxAttempts defaultWebConfig `shouldBe` 5 - wcLoginWindow defaultWebConfig `shouldBe` 5 * 60 - wcPageSize defaultWebConfig `shouldBe` 25 - wcMaxPageSize defaultWebConfig `shouldBe` 500 - wcMaxRows defaultWebConfig `shouldBe` 1000 - wcMaxSqlLength defaultWebConfig `shouldBe` 20000 - wcBodyLimit defaultWebConfig `shouldBe` 65536 - - describe "Config: environment variables are ignored (the file comes from --config)" $ do - it "port / host / static dir / limits in the environment change nothing" $ do - tmp <- getTemporaryDirectory - let missing = tmp "chusql-web-test-ignored-env.toml" - removeIfExists missing - withEnv - [ ("CHUSQL_WEB_PORT", "8123") - , ("CHUSQL_WEB_HOST", "0.0.0.0") - , ("CHUSQL_WEB_STATIC", "mysite") - , ("CHUSQL_WEB_SESSION_IDLE", "60") - , ("CHUSQL_WEB_SESSION_MAX", "120") - , ("CHUSQL_WEB_LOGIN_MAX_ATTEMPTS", "3") - , ("CHUSQL_WEB_LOGIN_WINDOW", "30") - , ("CHUSQL_WEB_PAGE_SIZE", "7") - , ("CHUSQL_WEB_MAX_PAGE_SIZE", "70") - , ("CHUSQL_WEB_MAX_ROWS", "9") - , ("CHUSQL_WEB_MAX_SQL_LENGTH", "1234") - , ("CHUSQL_WEB_BODY_LIMIT", "2048") - , ("CHUSQL_WEB_COOKIE_SECURE", "1") - ] - $ do - cfg <- loadWebConfigAt missing - cfg `shouldBe` defaultWebConfig - it "the retired endpoint variables change nothing either" $ do - tmp <- getTemporaryDirectory - let missing = tmp "chusql-web-test-ignored-endpoint.toml" - removeIfExists missing - withEnv - [ ("CHUSQL_PIPE", "from-env-pipe") - , ("CHUSQL_DATA_DIR", "from-env-data") - , ("CHUSQL_STORAGE_SERVER", "from-env-server") - ] - $ do - cfg <- loadWebConfigAt missing - wcPipeName cfg `shouldBe` Nothing - wcDataDir cfg `shouldBe` Nothing - wcStorageServer cfg `shouldBe` Nothing - - describe "Config: the sections of the global chusql.toml" $ do - it "reads the section and accepts both underscore and hyphen spellings" $ do - tmp <- getTemporaryDirectory - let path = tmp "chusql-web-test-global.toml" - writeFile path "[page]\nsize = 8192\n\n[web]\nstatic_dir = \"webroot\"\nsession_idle = 4242\nrows_per_page = 33\n" - cfg <- loadWebConfigAt path - wcStaticDir cfg `shouldBe` "webroot" - wcSessionIdle cfg `shouldBe` 4242 - wcPageSize cfg `shouldBe` 33 - -- 别的层的分区不归 web 管,没给的键回到内置默认 - wcPort cfg `shouldBe` 7777 - wcMaxRows cfg `shouldBe` 1000 - saved <- readSettingsFile path - Map.lookup "rows-per-page" saved `shouldBe` Just "33" - it "the endpoint comes from [server] pipe_name and [storage] data_dir, the executable from [web] storage_server" $ do - tmp <- getTemporaryDirectory - let path = tmp "chusql-web-test-endpoint.toml" - writeFile - path - "[server]\npipe_name = \"joint-pipe\"\n\n[storage]\ndata_dir = \"../localdata\"\n\n[web]\nstorage_server = \"chusql-storage.exe\"\n" - cfg <- loadWebConfigAt path - wcPipeName cfg `shouldBe` Just "joint-pipe" - resolvePipeName cfg `shouldBe` "joint-pipe" - wcDataDir cfg `shouldBe` Just "../localdata" - wcStorageServer cfg `shouldBe` Just "chusql-storage.exe" - it "without a file the pipe name falls back to the shared default" $ do - resolvePipeName defaultWebConfig `shouldBe` "chusql-joint" - it "a port in the environment no longer wins over the file" $ do - tmp <- getTemporaryDirectory - let path = tmp "chusql-web-test-global-env.toml" - writeFile path "[web]\nport = 1234\nhost = \"0.0.0.0\"\n" - withEnv [("CHUSQL_WEB_PORT", "4321")] $ do - cfg <- loadWebConfigAt path - wcPort cfg `shouldBe` 1234 - wcHost cfg `shouldBe` "0.0.0.0" - it "the canonical hyphen key wins when both spellings are present" $ do - let both = Map.fromList [("session-idle", "1"), ("session_idle", "2")] - Map.lookup "session-idle" (canonicalSettingKeys both) `shouldBe` Just "1" - it "a missing config file falls back to the built-in defaults" $ do - tmp <- getTemporaryDirectory - let missing = tmp "chusql-web-test-missing-config.toml" - removeIfExists missing - cfg <- loadWebConfigAt missing - wcPort cfg `shouldBe` wcPort defaultWebConfig - wcPageSize cfg `shouldBe` wcPageSize defaultWebConfig - - describe "Spawning the storage process" $ do - it "points the child at the same config file on its command line" $ do - storageChildArgs ("scripts" "chusql.toml") - `shouldBe` ["--config", "scripts" "chusql.toml"] - it "passes nothing else" $ do - storageChildArgs "chusql.toml" `shouldBe` ["--config", "chusql.toml"] - - describe "Installer scripts (scripts/)" $ do - it "install.sh releases files, writes chusql.toml, sets PATH and removes the package" $ do - installer <- readUtf8 (".." "scripts" "install.sh") - installer `shouldSatisfy` T.isInfixOf "chusql-storage" - installer `shouldSatisfy` T.isInfixOf "chusql.toml" - installer `shouldSatisfy` T.isInfixOf "PATH" - installer `shouldSatisfy` T.isInfixOf "rm -rf" - -- 一份脚本三种来源:包内(旁边有 bin/)、在线(管道进来时 $0 是 sh,按平台取 release 资源)、 - -- 显式归档或源码。去掉任何一条,从 GitHub 装的用法就断了。 - it "install.sh picks the package by platform and can fall back to source" $ do - installer <- readUtf8 (".." "scripts" "install.sh") - installer `shouldSatisfy` T.isInfixOf "bin/chusql-storage" - installer `shouldSatisfy` T.isInfixOf "uname -s" - installer `shouldSatisfy` T.isInfixOf "uname -m" - installer `shouldSatisfy` T.isInfixOf "releases/latest/download" - installer `shouldSatisfy` T.isInfixOf "chusql-$component-$label.$ext" - installer `shouldSatisfy` T.isInfixOf ".sha256" - installer `shouldSatisfy` T.isInfixOf "--url" - installer `shouldSatisfy` T.isInfixOf "--from-source" - installer `shouldSatisfy` T.isInfixOf "stack build --fast" - -- static_dir 是相对路径,启动器必须先切到安装目录;少了这一句,从别处执行 - -- csql-web 就会因为找不到 static/ 直接退出(Windows 的 .ps1 靠 -WorkingDirectory)。 - it "csql-web.sh switches to the install dir before starting" $ do - launcher <- readUtf8 (".." "scripts" "csql-web.sh") - launcher `shouldSatisfy` T.isInfixOf "cd \"$home_dir\"" - launcher `shouldSatisfy` T.isInfixOf "chusql-storage" - -- 默认数据目录按平台惯例算,四处必须说同一件事:Rust 代码、配置示例、模板、文档。 - -- 跟安装脚本装的位置也是一处:Windows 装到 %LOCALAPPDATA%\ChuSQL、Unix 装到 ~/.local/share/chusql, - -- 数据都放在它下面的 data/ 里。 - it "the default data dir follows the platform, and code, template and docs agree" $ do - rust <- readUtf8 (".." "chusql-storage" "src" "config.rs") - rust `shouldSatisfy` T.isInfixOf "default_data_dir" - rust `shouldSatisfy` T.isInfixOf "LOCALAPPDATA" - rust `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" - rust `shouldSatisfy` (not . T.isInfixOf "DEFAULT_DATA_DIR") - - example <- readUtf8 (".." "chusql-storage" "chusql-storage.toml.example") - -- 示例文件说自己列的值就是默认值:data_dir 不能是能生效的一行 - example `shouldSatisfy` (not . T.isInfixOf "\ndata_dir =") - example `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" - example `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" - - template <- readUtf8 (".." "scripts" "chusql.toml") - -- 模板那行是安装时被 sed / -replace 替换的占位,必须保持能生效 - template `shouldSatisfy` T.isInfixOf "\ndata_dir = " - template `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" - - configDoc <- readUtf8 (".." "doc" "config.md") - configDoc `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%\\ChuSQL\\data" - configDoc `shouldSatisfy` T.isInfixOf "chusql/data" - -- 端点规则两侧必须一致:Rust 服务端和 Haskell 前端算出来的必须是同一个路径。 - it "Rust and Haskell agree on where the Unix socket lives" $ do - rustEndpoint <- readUtf8 (".." "chusql-storage" "src" "endpoint.rs") - haskellIpc <- readUtf8 (".." "chusql-engine" "src" "ChuSQL" "Storage" "IPC.hs") - mapM_ - ( \src -> do - src `shouldSatisfy` T.isInfixOf "XDG_RUNTIME_DIR" - src `shouldSatisfy` T.isInfixOf "TMPDIR" - src `shouldSatisfy` T.isInfixOf "/tmp" - src `shouldSatisfy` T.isInfixOf ".sock" - ) - [rustEndpoint, haskellIpc] - it "the Linux pipeline builds both toolchains and runs every test suite" $ do - pipeline <- readUtf8 (".." ".github" "workflows" "linux.yml") - pipeline `shouldSatisfy` T.isInfixOf "ubuntu-latest" - pipeline `shouldSatisfy` T.isInfixOf "cargo test --release" - pipeline `shouldSatisfy` T.isInfixOf "cargo clippy" - pipeline `shouldSatisfy` T.isInfixOf "stack test --fast" - -- 打包与安装冒烟只在发版流水线里做(tag 触发 / 手动 dispatch),这条流水线不再出包 - pipeline `shouldSatisfy` (not . T.isInfixOf "package.ps1") - pipeline `shouldSatisfy` (not . T.isInfixOf "smoke-linux.sh") - -- 仓库里没有 rustfmt.toml,历史代码不是按当前 rustfmt 排的:格式化不是门禁 - pipeline `shouldSatisfy` (not . T.isInfixOf "cargo fmt") - -- 装的人不该被要求装两个工具链:发版流水线出预编译包,install.sh 按同一套平台标签去取。 - -- 装配与打归档原来在 scripts/package.ps1 里,脚本删掉后直接写在流水线里(两个平台各一段)。 - it "the release pipeline publishes one archive per platform, tagged the way install.sh looks it up" $ do - release <- readUtf8 (".." ".github" "workflows" "release.yml") - release `shouldSatisfy` T.isInfixOf "linux-x86_64" - release `shouldSatisfy` T.isInfixOf "macos-arm64" - release `shouldSatisfy` T.isInfixOf "macos-x86_64" - release `shouldSatisfy` T.isInfixOf "windows-x86_64" - release `shouldSatisfy` T.isInfixOf "cargo build --release" - release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-web:exe:chusql-web" - release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-cli:exe:csql" - release `shouldSatisfy` T.isInfixOf "tar -czf" - release `shouldSatisfy` T.isInfixOf "Compress-Archive" - release `shouldSatisfy` T.isInfixOf "scripts/install.sh" - release `shouldSatisfy` (not . T.isInfixOf "package.ps1") - release `shouldSatisfy` T.isInfixOf "sha256" - release `shouldSatisfy` T.isInfixOf "gh release upload" - it "the storage binary name follows the platform (a stray .exe must not win on Linux)" $ do - platformBinaryName "mingw32" "chusql-storage" `shouldBe` "chusql-storage.exe" - platformBinaryName "linux" "chusql-storage" `shouldBe` "chusql-storage" - -- 同一 target 目录里可能躺着一份别处交叉编译出来的 .exe:谁都不许再写 - -- 「.exe 优先、无后缀兜底」这种候选表 - engine <- readUtf8 (".." "chusql-engine" "test" "Spec.hs") - bench <- readUtf8 (".." "benchmark" "src" "Main.hs") - storage <- readUtf8 ("src" "ChuSQL" "Web" "StorageProcess.hs") - mapM_ - (\src -> src `shouldSatisfy` (not . T.isInfixOf "\"chusql-storage.exe\", \"chusql-storage\"")) - [engine, bench, storage] - - -- 细节分到 doc/ 下四份,README 只留面向用户的关键内容;链接断了或者文档没了,等于没写。 - describe "Documentation (doc/)" $ do - it "the README keeps only the essentials and links the split documents" $ do - readme <- readUtf8 (".." "README.md") - mapM_ - (\target -> readme `shouldSatisfy` T.isInfixOf ("doc/" <> target <> ".md")) - ["install", "config", "commands", "architecture"] - -- 安装选项与配置键的长表已经搬走,不该在 README 里再长回来 - readme `shouldSatisfy` (not . T.isInfixOf "--from-source") - readme `shouldSatisfy` (not . T.isInfixOf "pool_size") - -- 仓库布局这类开发者信息属于 doc/architecture.md,不属于 README - readme `shouldSatisfy` (not . T.isInfixOf "chusql-engine/") - it "each split document covers what its name promises" $ do - let expectations = - [ ("install", ["install.sh", "install.ps1", "--component", "--install-dir", "--from-source", "rm -rf"]) - , ("config", ["[web]", "[page]", "pipe_name", "data_dir", "host", "port"]) - , ("commands", ["csql", "--format", "\\dt", "CREATE ROLE", "GRANT", "/api/roles"]) - , ("architecture", ["chusql-storage", "chusql-web", "csql", "pipe_name", "data_dir", "CREATE DATABASE"]) - ] - mapM_ - ( \(name, needles) -> do - doc <- readUtf8 (".." "doc" name <> ".md") - mapM_ (\needle -> doc `shouldSatisfy` T.isInfixOf needle) needles - ) - expectations - -- doc/ 只写面向用户的内容:构建、测试、打包这些开发流程不进文档 - it "the split documents stay user-facing (no build, test or packaging recipes)" $ do - let developerOnly = - [ "package.ps1" - , "stack build" - , "stack test" - , "cargo build" - , "cargo test" - , "cargo clippy" - , ".stack-work" - , ".github/workflows" - , "smoke-linux.sh" - ] - mapM_ - ( \name -> do - doc <- readUtf8 (".." "doc" name <> ".md") - mapM_ (\needle -> doc `shouldSatisfy` (not . T.isInfixOf needle)) developerOnly - ) - ["install", "config", "commands", "architecture"] - -staticSpec :: FilePath -> Spec -staticSpec staticDir = describe "Static files" $ do - it "an unreadable file gives Nothing" $ - readStatic staticDir "nope.js" `shouldReturn` Nothing - --- | 手写静态前端:源文件入库,没有打包器,也不该再有 Node 工具链 -staticAssetSpec :: Spec -staticAssetSpec = describe "Static front end (hand-written, no bundler)" $ do - let dir = "static" - asset name = dir name - readAsset name = readUtf8 (asset name) - it "the app shell is present and loads both assets from /static/" $ do - html <- readAsset "index.html" - html `shouldSatisfy` T.isInfixOf "id=\"root\"" - html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\"" - html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\"" - -- 服务端把 nonce 元信息插在 前面,这个标记不能丢 - html `shouldSatisfy` T.isInfixOf "" - it "the shell has no inline script or style (the CSP allows neither)" $ do - html <- readAsset "index.html" - html `shouldSatisfy` (not . T.isInfixOf "