Files
knowledge-governance/internal/workflows/artifactfetch/handler_env_test.go
姚章浩 d29e04e647 fix(artifact): 白名单环境漏传 dws 变量导致钉钉取件整体失败
端到端验收实测(2026-10-10):标注后取件以 artifact_fetch_rejected: credentials 终态失败。
根因:artifactfetch runner 给子进程的是白名单环境(防凭据外泄),但没有透传 dws 取件器
必需的 ARTIFACT_DINGTALK_FETCHER/DWS_BIN/DWS_CONFIG_DIR/DWS_HOME/DWS_DISABLE_KEYCHAIN
→ dws 找不到配置目录与文件型 keychain($HOME/.local/share/dws-cli 落空)→ 报未登录 →
映射成 credentials 终态(不可重试)→ 且不会回落老路径。脚本单跑正常,因为绕过了白名单。

- handler.go: childEnv() 显式透传 dws 变量(仅非空值);失败时把 stderr 尾部脱敏后带进错误,
  否则只能看到裸 code 无法定位(本次排查就卡在这)
- 新增 3 个回归测试:环境透传 / 空值不透传 / stderr 上报与敏感内容抑制
- 实测:修复后重新入队 → artifact.fetch completed,产物 sn-753f….docx 4799B(adoc→docx 导出),
  content_objects 记为 docx/verified
2026-10-10 09:57:41 +08:00

92 lines
3.3 KiB
Go

package artifactfetch
import (
"context"
"os/exec"
"path/filepath"
"strings"
"testing"
"os"
)
// 回归(2026-10-10 端到端验收实测):取件子进程用的是白名单环境(为防凭据外泄),
// dws 取件器的运行变量必须显式透传。漏传时 dws 找不到配置目录与文件型 keychain,
// 报"未登录"→ 被映射成 credentials 终态 → 钉钉取件在真实链路上整体失败
// (脚本单跑却正常,因为那只绕过了这个白名单)。
func TestChildEnvForwardsDWSVariables(t *testing.T) {
t.Setenv("DWS_HOME", "/data/home")
t.Setenv("DWS_CONFIG_DIR", "/data/dws")
t.Setenv("DWS_DISABLE_KEYCHAIN", "1")
t.Setenv("ARTIFACT_DINGTALK_FETCHER", "auto")
t.Setenv("DWS_BIN", "/usr/local/bin/dws")
t.Setenv("PGPASSWORD", "should-not-leak")
runner, err := NewCommandRunner("/usr/bin/python3", "/app/scripts/artifact_fetch.py", "/spool", "", 1024)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(runner.childEnv(), "\n")
for _, want := range []string{
"DWS_HOME=/data/home", "DWS_CONFIG_DIR=/data/dws", "DWS_DISABLE_KEYCHAIN=1",
"ARTIFACT_DINGTALK_FETCHER=auto", "DWS_BIN=/usr/local/bin/dws",
"INGESTION_SPOOL_ROOT=/spool", "TZ=UTC",
} {
if !strings.Contains(joined, want) {
t.Errorf("childEnv 缺少 %q", want)
}
}
if strings.Contains(joined, "PGPASSWORD") {
t.Fatal("白名单之外的变量不得进入子进程环境")
}
}
// 未设置时不透传空值(让 dws 用自己的默认值)。
func TestChildEnvOmitsUnsetDWSVariables(t *testing.T) {
for _, name := range dwsEnvNames {
t.Setenv(name, "")
}
runner, err := NewCommandRunner("/usr/bin/python3", "/app/scripts/artifact_fetch.py", "/spool", "", 1024)
if err != nil {
t.Fatal(err)
}
if joined := strings.Join(runner.childEnv(), "\n"); strings.Contains(joined, "DWS_HOME=") {
t.Fatalf("空值不应透传:%s", joined)
}
}
// 失败时把脚本 stderr 尾部脱敏后带进错误,否则操作者只能看到裸错误码(本次排查就卡在这)。
func TestRunSurfacesSanitizedStderrOnFailure(t *testing.T) {
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 not available")
}
dir := t.TempDir()
script := filepath.Join(dir, "fake_fetch.py")
payload := "import json,sys\n" +
"sys.stderr.write('dws: 未登录,请先执行 dws auth login\\n')\n" +
"print(json.dumps({'ok': False, 'code': 'credentials', 'error': 'not authenticated', 'node_id': 'sn-x'}))\n"
if err := os.WriteFile(script, []byte(payload), 0o644); err != nil {
t.Fatal(err)
}
runner, err := NewCommandRunner(python, script, dir, "", 4096)
if err != nil {
t.Fatal(err)
}
result, err := runner.Run(context.Background(), Manifest{Request: Request{NodeID: "sn-x"}})
if err != nil {
t.Fatal(err)
}
if result.OK || result.Code != "credentials" {
t.Fatalf("应返回失败结果:%+v", result)
}
if !strings.Contains(result.StderrTail, "dws") {
t.Fatalf("应带回 stderr 尾部,实际 %q", result.StderrTail)
}
if detail := stderrDetail(result.StderrTail); !strings.Contains(detail, "未登录") {
t.Fatalf("stderrDetail 应含原因,实际 %q", detail)
}
if suppressed := stderrDetail("Authorization: Bearer abc https://example/x"); strings.Contains(suppressed, "Bearer") {
t.Fatalf("敏感内容必须被抑制,实际 %q", suppressed)
}
}