package main import ( "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestBuildRemote(t *testing.T) { env, err := buildRemote(remoteSpec{Type: "s3", Params: map[string]string{"provider": "Minio", "endpoint": "http://127.0.0.1:9198", "bucket": "kb", "prefix": "/docs/2024/"}, Secrets: map[string]string{"accessKeyId": "ak", "secretAccessKey": "sk"}}, "") if err != nil { t.Fatal(err) } if env.path != "kb:kb/docs/2024" { t.Errorf("远程路径:%s", env.path) } joined := strings.Join(env.env, "\n") for _, want := range []string{"RCLONE_CONFIG_KB_TYPE=s3", "RCLONE_CONFIG_KB_PROVIDER=Minio", "RCLONE_CONFIG_KB_ENDPOINT=http://127.0.0.1:9198", "RCLONE_CONFIG_KB_SECRET_ACCESS_KEY=sk", "RCLONE_CONFIG_KB_ENV_AUTH=false"} { if !strings.Contains(joined, want) { t.Errorf("缺少 %s", want) } } bad := []remoteSpec{ {Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "ak"}}, {Type: "s3", Params: map[string]string{"endpoint": "ftp://x", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}}, {Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "-rf"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}}, {Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "kb", "prefix": "a/../../b"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}}, {Type: "s3", Params: map[string]string{"endpoint": "http://x\nRCLONE_X=1", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}}, {Type: "webdav", Params: map[string]string{"url": "http://u:p@dav.local/"}}, {Type: "webdav", Params: map[string]string{"url": "http://dav.local/", "vendor": "evil"}}, {Type: "local", Params: map[string]string{"path": "/"}}, {Type: "sftp"}, } for i, r := range bad { if _, err := buildRemote(r, ""); err == nil { t.Errorf("第 %d 个配置应该被拒绝:%+v", i, r) } } } func TestClassify(t *testing.T) { cases := map[string]string{ "ERROR : api error SignatureDoesNotMatch: The request signature we calculated does not match": "auth_failed", "ERROR : https response error StatusCode: 403, api error AccessDenied: Access Denied.": "auth_failed", "ERROR : api error NoSuchBucket: The specified bucket does not exist": "path_missing", `ERROR : request send failed, Get "http://127.0.0.1:1/x": dial tcp 127.0.0.1:1: connect: connection refused`: "unreachable", "ERROR : dial tcp: lookup nas.local: no such host": "unreachable", } for in, want := range cases { if st, msg := classify(in); st != want || msg == "" { t.Errorf("%q → %s %q,应为 %s", in, st, msg, want) } } if _, msg := classify("2026/11/06 ERROR : api error AccessDenied: Access Denied."); msg != "认证失败或没有权限:AccessDenied:Access Denied." { t.Errorf("错误信息:%q", msg) } long := `ERROR : error listing: operation error S3: ListObjectsV2, exceeded maximum number of attempts, 1, request send failed, Get "http://127.0.0.1:1/kb?x=1": dial tcp 127.0.0.1:1: connect: connection refused` if _, msg := classify(long); msg != "无法连接:dial tcp 127.0.0.1:1: connect: connection refused" { t.Errorf("网络错误信息:%q", msg) } } // 需要 rclone:RCLONE_BIN 或 PATH 中的 rclone;用 rclone 的 local 后端,不需要网络 func rcloneBin(t *testing.T) string { if b := os.Getenv("RCLONE_BIN"); b != "" { return b } if b, err := exec.LookPath("rclone"); err == nil { return b } t.Skip("没有 rclone(设置 RCLONE_BIN)") return "" } func TestRemoteMount(t *testing.T) { needFuse(t) bin := rcloneBin(t) allowLocalRemote = true defer func() { allowLocalRemote = false }() base, roots := tempRoots(t) data := filepath.Join(base, "remote-data") os.MkdirAll(filepath.Join(data, "产品手册", ".git"), 0o755) os.WriteFile(filepath.Join(data, "产品手册", "规格.csv"), []byte("型号,功率\nA100,5\n"), 0o644) os.WriteFile(filepath.Join(data, ".env"), []byte("SECRET=1"), 0o644) dir := filepath.Join(base, "mounter") mntDir := filepath.Join(base, "kbmnt") for _, d := range []string{dir, mntDir, filepath.Join(dir, "remote"), filepath.Join(dir, "cache")} { os.MkdirAll(d, 0o755) } conf := filepath.Join(dir, "rclone.conf") os.WriteFile(conf, nil, 0o600) m := newManager(realSystem{}, roots, mntDir, filepath.Join(dir, "state.json")).withRemote(&rcloneRunner{bin: bin, config: conf, home: dir}, filepath.Join(dir, "remote"), filepath.Join(dir, "cache")) defer m.unmountAll() spec := mountSpec{ID: "kb_r1", Kind: "remote", Remote: &remoteSpec{Type: "local", Params: map[string]string{"path": data}}} st, err := m.sync([]mountSpec{spec}, true) if err != nil || len(st) != 1 || st[0].State != "ok" { t.Fatalf("远程存储挂载:%+v %v", st, err) } mp := filepath.Join(mntDir, "kb_r1") if got := names(t, mp); len(got) != 1 || got[0] != "产品手册" { t.Errorf("目录列表(隐藏文件不可见):%v", got) } if b, err := os.ReadFile(filepath.Join(mp, "产品手册", "规格.csv")); err != nil || !strings.Contains(string(b), "A100") { t.Errorf("读取:%q %v", b, err) } if _, err := os.Stat(filepath.Join(mp, ".env")); !errors.Is(err, os.ErrNotExist) { t.Errorf(".env 应该不存在:%v", err) } if err := os.WriteFile(filepath.Join(mp, "x.txt"), []byte("x"), 0o644); err == nil { t.Error("应该不能写入") } // 凭据不出现在命令行 m.mu.Lock() cmd := m.mounts["kb_r1"].remote.cmd m.mu.Unlock() if strings.Contains(strings.Join(cmd.Args, " "), "RCLONE_CONFIG") { t.Errorf("命令行中不应有配置:%v", cmd.Args) } // 配置错误:状态为挂载失败 bad := mountSpec{ID: "kb_r2", Kind: "remote", Remote: &remoteSpec{Type: "s3", Params: map[string]string{"bucket": "x"}}} st, _ = m.sync([]mountSpec{spec, bad}, false) if st[1].State != "mount_failed" { t.Errorf("配置错误:%+v", st[1]) } // 删除:停止 rclone、卸载、清理缓存 if _, err := m.sync(nil, false); err != nil { t.Fatal(err) } time.Sleep(200 * time.Millisecond) for _, p := range []string{mp, filepath.Join(dir, "remote", "kb_r1"), filepath.Join(dir, "cache", "kb_r1")} { if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) { t.Errorf("%s 应该已删除:%v", p, err) } } if cmd.ProcessState == nil { t.Error("rclone 应该已退出") } }