From 296e9a73f0385b5579113d59da379e1798dd3091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 16:04:17 +0800 Subject: [PATCH 01/11] fix(auth): reject unsupported Windows portable export --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 4 ++ README.md | 3 ++ internal/app/auth_command.go | 11 ++--- internal/app/auth_command_test.go | 47 +++++++++++++++++++++ internal/auth/portable_store.go | 31 ++++++++++---- internal/auth/portable_store_test.go | 63 ++++++++++++++++++++++++---- 7 files changed, 140 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbee297..7a943aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,8 +103,8 @@ jobs: - name: Test Windows auth and DPAPI packages run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth - - name: Test Windows auth migration diagnostics - run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)' + - name: Test Windows auth migration and export diagnostics + run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend)' coverage-darwin: name: Coverage (macOS) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc449980..7e488f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier. +### Fixed + +- **Windows portable-auth export contract** — `dws auth export` now fails early with a validation error instead of claiming portable-bundle support for credentials stored in DPAPI-protected HKCU Registry values. + ## [1.0.52] - 2026-07-14 This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery. diff --git a/README.md b/README.md index b9229e83..6fc4a709 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,9 @@ dws auth status # confirm "Refresh Token: valid" ``` The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files. +Windows export is intentionally rejected before credentials are read: Windows +stores them as DPAPI-protected HKCU Registry values, and the current file-DEK +bundle has no safe DPAPI-to-portable conversion. diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 6aee387c..df76f520 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -631,6 +631,10 @@ func pushRuntimeProfile(selector string) func() { } func newAuthExportCommand() *cobra.Command { + return newAuthExportCommandWithSupport(authpkg.PortableExportSupportError) +} + +func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command { cmd := &cobra.Command{ Use: "export", Short: "导出可迁移认证包", @@ -655,11 +659,8 @@ func newAuthExportCommand() *cobra.Command { if !asBase64 && output == "" { return apperrors.NewValidation("--output is required unless --base64 is used") } - if !authpkg.PortableExportSupported() { - return apperrors.NewValidation(fmt.Sprintf( - "macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录", - keychain.DisableKeychainEnv, - )) + if err := supportError(); err != nil { + return apperrors.NewValidation(err.Error()) } if !authpkg.PortableAuthSourceReady() { return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend") diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 0da3b831..31447da5 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -97,6 +98,52 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) { } } +func TestAuthExportUnsupportedBackendIsValidationError(t *testing.T) { + exportCmd := newAuthExportCommandWithSupport(func() error { + return errors.New("portable auth export is unavailable for the test backend") + }) + exportCmd.SetOut(&bytes.Buffer{}) + exportCmd.SetErr(&bytes.Buffer{}) + exportCmd.SetArgs([]string{"--base64"}) + + err := exportCmd.Execute() + if err == nil { + t.Fatal("auth export should reject an unsupported credential backend") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), "test backend") { + t.Fatalf("error = %v, want backend-specific reason", err) + } +} + +func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows DPAPI contract requires a native Windows runner") + } + + exportCmd := NewRootCommand() + exportCmd.SetOut(&bytes.Buffer{}) + exportCmd.SetErr(&bytes.Buffer{}) + exportCmd.SetArgs([]string{"auth", "export", "--base64"}) + + err := exportCmd.Execute() + if err == nil { + t.Fatal("auth export should reject the Windows DPAPI backend") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + for _, want := range []string{"Windows", "DPAPI", "HKCU"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want substring %q", err, want) + } + } +} + func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index 91b1c538..e13f64dc 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -37,13 +37,30 @@ type PortableImportReport struct { OSMismatch bool } -// PortableExportSupported reports whether the current platform can produce a -// bundle that includes the file-based DEK required for import elsewhere. +// PortableExportSupported reports whether the current credential backend can +// produce a bundle that includes the file-based DEK required for import +// elsewhere. func PortableExportSupported() bool { - if runtime.GOOS != "darwin" { - return true + return PortableExportSupportError() == nil +} + +// PortableExportSupportError explains why the current credential backend +// cannot produce a portable auth bundle. A nil error means export is +// supported. +func PortableExportSupportError() error { + return portableExportSupportError(runtime.GOOS, os.Getenv(keychain.DisableKeychainEnv)) +} + +func portableExportSupportError(goos, disableKeychain string) error { + switch goos { + case "windows": + return fmt.Errorf("portable auth export is not supported on Windows because credentials are stored as DPAPI-protected HKCU Registry values; a portable conversion is not implemented") + case "darwin": + if disableKeychain == "" { + return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv) + } } - return os.Getenv(keychain.DisableKeychainEnv) != "" + return nil } // PortableAuthTargetPopulated reports whether local auth files would be @@ -96,8 +113,8 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error { if w == nil { return fmt.Errorf("missing output writer") } - if !PortableExportSupported() { - return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv) + if err := PortableExportSupportError(); err != nil { + return err } keychainDir := keychain.StorageDir(keychain.Service) if _, err := os.Stat(keychainDir); err != nil { diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go index f3c7764c..f5ace527 100644 --- a/internal/auth/portable_store_test.go +++ b/internal/auth/portable_store_test.go @@ -18,6 +18,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -25,19 +26,63 @@ import ( ) func TestPortableExportSupported(t *testing.T) { - if runtime.GOOS != "darwin" { - if !PortableExportSupported() { - t.Fatal("PortableExportSupported() should be true on non-darwin") + switch runtime.GOOS { + case "windows": + if PortableExportSupported() { + t.Fatal("PortableExportSupported() should be false for the Windows DPAPI backend") + } + var bundle bytes.Buffer + err := ExportPortableAuthBundle(t.TempDir(), &bundle) + if err == nil || !strings.Contains(err.Error(), "DPAPI") { + t.Fatalf("ExportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err) + } + if bundle.Len() != 0 { + t.Fatalf("ExportPortableAuthBundle() wrote %d bytes on unsupported Windows backend", bundle.Len()) } return + case "darwin": + t.Setenv(keychain.DisableKeychainEnv, "") + if PortableExportSupported() { + t.Fatal("PortableExportSupported() should be false on darwin without file DEK") + } + t.Setenv(keychain.DisableKeychainEnv, "1") + if !PortableExportSupported() { + t.Fatal("PortableExportSupported() should be true when file DEK is enabled") + } + return + default: + if !PortableExportSupported() { + t.Fatal("PortableExportSupported() should be true for file-DEK platforms") + } } - t.Setenv(keychain.DisableKeychainEnv, "") - if PortableExportSupported() { - t.Fatal("PortableExportSupported() should be false on darwin without file DEK") +} + +func TestPortableExportSupportError(t *testing.T) { + tests := []struct { + name string + goos string + disableKeychain string + wantError string + }{ + {name: "windows dpapi", goos: "windows", disableKeychain: "1", wantError: "DPAPI"}, + {name: "darwin keychain", goos: "darwin", wantError: keychain.DisableKeychainEnv}, + {name: "darwin file dek", goos: "darwin", disableKeychain: "1"}, + {name: "linux file dek", goos: "linux"}, } - t.Setenv(keychain.DisableKeychainEnv, "1") - if !PortableExportSupported() { - t.Fatal("PortableExportSupported() should be true when file DEK is enabled") + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := portableExportSupportError(test.goos, test.disableKeychain) + if test.wantError == "" { + if err != nil { + t.Fatalf("portableExportSupportError() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("portableExportSupportError() error = %v, want substring %q", err, test.wantError) + } + }) } } From 5e41b8a6e87f43c73c3f64b2ecfe3edd5e35fb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 16:45:57 +0800 Subject: [PATCH 02/11] test(windows): make app coverage portable --- internal/app/auth_command_test.go | 7 +++++ internal/app/config_test.go | 2 +- .../app/schema_agent_examples_dry_run_test.go | 6 ++-- internal/app/skill_command_test.go | 2 ++ internal/app/skill_setup_test.go | 8 +++--- internal/app/test_home_test.go | 28 +++++++++++++++++++ internal/app/testmain_test.go | 12 ++++++++ internal/app/timing_test.go | 6 ++-- 8 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 internal/app/test_home_test.go diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 31447da5..8af273e9 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -37,6 +37,10 @@ import ( ) func TestAuthExportImportBase64RoundTrip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("portable auth export is intentionally unsupported on Windows") + } + t.Setenv(keychain.DisableKeychainEnv, "1") sourceKeychain := filepath.Join(t.TempDir(), "source-keychain") sourceConfig := filepath.Join(t.TempDir(), ".dws") @@ -123,6 +127,7 @@ func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } + t.Cleanup(CloseFileLogger) exportCmd := NewRootCommand() exportCmd.SetOut(&bytes.Buffer{}) @@ -147,6 +152,7 @@ func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() + t.Cleanup(CloseFileLogger) configDir := filepath.Join(root, ".dws") t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) t.Setenv("DWS_CONFIG_DIR", configDir) @@ -1120,6 +1126,7 @@ func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string ResetRuntimeTokenCache() clearCompatCache() t.Cleanup(func() { + CloseFileLogger() authpkg.SetRuntimeProfile("") ResetRuntimeTokenCache() clearCompatCache() diff --git a/internal/app/config_test.go b/internal/app/config_test.go index c78c3c49..ae2ea234 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -7,7 +7,7 @@ import ( func TestDefaultConfigDirUsesHomeDirectoryInOSSMode(t *testing.T) { homeDir := filepath.Join(t.TempDir(), "home") - t.Setenv("HOME", homeDir) + setTestHome(t, homeDir) t.Setenv("DWS_CONFIG_DIR", "") got := defaultConfigDir() diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index b3284eb6..fa4eca28 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -67,7 +67,7 @@ func TestManualAgentExamplesDryRun(t *testing.T) { t.Fatalf("create isolated test directory %s: %v", dir, err) } } - t.Setenv("HOME", homeDir) + setTestHome(t, homeDir) t.Setenv("DWS_CONFIG_DIR", configDir) t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") @@ -470,7 +470,7 @@ func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testi } func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) args := []string{ @@ -518,7 +518,7 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin if err := os.MkdirAll(configDir, 0o700); err != nil { t.Fatalf("create isolated config directory: %v", err) } - t.Setenv("HOME", sandboxRoot) + setTestHome(t, sandboxRoot) t.Setenv("DWS_CONFIG_DIR", configDir) capture, err := executeManualAgentExampleCapture(t, []string{ diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 143b0dd1..226afffc 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -369,6 +369,7 @@ func TestSkillInstallCommandValidation(t *testing.T) { func TestSkillInstallInvalidTarget(t *testing.T) { // Setup: Create config directory with valid token tempDir := t.TempDir() + t.Cleanup(CloseFileLogger) configDir := filepath.Join(tempDir, "config") t.Setenv("DWS_CONFIG_DIR", configDir) @@ -402,6 +403,7 @@ func TestSkillInstallInvalidTarget(t *testing.T) { func TestSkillInstallRequiresAuth(t *testing.T) { // Setup: Create config directory without token tempDir := t.TempDir() + t.Cleanup(CloseFileLogger) configDir := filepath.Join(tempDir, "config") t.Setenv("DWS_CONFIG_DIR", configDir) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index d35c5d77..de3f2cba 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -88,7 +88,7 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) { // Isolate HOME so the ~/.dws/skills// fallback (added by the release // pipeline cache work) does not pick up real cached content on the // developer machine. - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) _, err := resolveSkillSetupSource(tmp, skillSetupModeMono) if err == nil { t.Fatalf("expected error when source missing") @@ -106,7 +106,7 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if len(got) != 1 { t.Fatalf("expected 1 dest, got %d", len(got)) } - if !strings.Contains(got[0], ".claude/skills/dws") { + if !strings.Contains(filepath.Clean(got[0]), filepath.Join(".claude", "skills", "dws")) { t.Fatalf("expected .claude/skills/dws path, got %s", got[0]) } } @@ -125,10 +125,10 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if len(got) != 1 { t.Fatalf("expected 1 dest, got %d", len(got)) } - if strings.HasSuffix(got[0], "/dws") { + if filepath.Base(got[0]) == "dws" { t.Fatalf("multi target must not end with /dws, got %s", got[0]) } - if !strings.HasSuffix(got[0], ".claude/skills") { + if !strings.HasSuffix(filepath.Clean(got[0]), filepath.Join(".claude", "skills")) { t.Fatalf("expected suffix .claude/skills, got %s", got[0]) } } diff --git a/internal/app/test_home_test.go b/internal/app/test_home_test.go new file mode 100644 index 00000000..23cbda2e --- /dev/null +++ b/internal/app/test_home_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "runtime" + "testing" +) + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + if runtime.GOOS == "windows" { + // os.UserHomeDir uses USERPROFILE on Windows rather than HOME. + t.Setenv("USERPROFILE", home) + } +} diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 76d74c17..d052a3ad 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -15,6 +15,7 @@ package app import ( "os" + "path/filepath" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" @@ -49,8 +50,19 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set DWS_CONFIG_DIR: " + err.Error()) + } + // Keep the process-wide audit sink outside per-test TempDir trees. Cobra + // skips PersistentPostRunE on expected command errors, and Windows cannot + // remove a TempDir while the audit lock is still open. + setupAuditSink() openBrowserFunc = func(string) error { return nil } code := m.Run() + StopAllStdioClients() + CloseAuditSink() + CloseFileLogger() _ = os.RemoveAll(tmpDir) os.Exit(code) } diff --git a/internal/app/timing_test.go b/internal/app/timing_test.go index 410c874c..bf8f8999 100644 --- a/internal/app/timing_test.go +++ b/internal/app/timing_test.go @@ -283,7 +283,7 @@ func TestWriteReportIfEnabled_Auto(t *testing.T) { expected := filepath.Join(tmpHome, ".dws", "perf", "latest.json") // Temporarily override HOME for defaultPerfReportPath - t.Setenv("HOME", tmpHome) + setTestHome(t, tmpHome) t.Setenv(PerfReportEnv, "auto") tc := NewTimingCollector() @@ -312,7 +312,7 @@ func TestWriteReportIfEnabled_NilCollector(t *testing.T) { func TestLoadLatestReport(t *testing.T) { tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) + setTestHome(t, tmpHome) perfDir := filepath.Join(tmpHome, ".dws", "perf") if err := os.MkdirAll(perfDir, 0o700); err != nil { @@ -348,7 +348,7 @@ func TestLoadLatestReport(t *testing.T) { func TestLoadLatestReport_NotFound(t *testing.T) { tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) + setTestHome(t, tmpHome) _, err := LoadLatestReport() if err == nil { From 48681eb41c0a988586a5fe46440689c68592ff92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 16:53:55 +0800 Subject: [PATCH 03/11] test: isolate app audit environment --- internal/app/skill_setup_test.go | 19 ++++++++++++------- internal/app/testmain_test.go | 23 ++++++++++++++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index de3f2cba..b95ecc65 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -99,6 +99,9 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) { } func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + got, err := resolveSkillSetupTargets("claude", skillSetupModeMono) if err != nil { t.Fatalf("unexpected err: %v", err) @@ -106,8 +109,9 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if len(got) != 1 { t.Fatalf("expected 1 dest, got %d", len(got)) } - if !strings.Contains(filepath.Clean(got[0]), filepath.Join(".claude", "skills", "dws")) { - t.Fatalf("expected .claude/skills/dws path, got %s", got[0]) + want := filepath.Join(home, ".claude", "skills", "dws") + if filepath.Clean(got[0]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[0]) } } @@ -118,6 +122,9 @@ func TestResolveSkillSetupTargetsUnknown(t *testing.T) { } func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti) if err != nil { t.Fatalf("unexpected err: %v", err) @@ -125,11 +132,9 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if len(got) != 1 { t.Fatalf("expected 1 dest, got %d", len(got)) } - if filepath.Base(got[0]) == "dws" { - t.Fatalf("multi target must not end with /dws, got %s", got[0]) - } - if !strings.HasSuffix(filepath.Clean(got[0]), filepath.Join(".claude", "skills")) { - t.Fatalf("expected suffix .claude/skills, got %s", got[0]) + want := filepath.Join(home, ".claude", "skills") + if filepath.Clean(got[0]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[0]) } } diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index d052a3ad..77b212ac 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -14,10 +14,12 @@ package app import ( + "fmt" "os" "path/filepath" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) @@ -54,6 +56,20 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set DWS_CONFIG_DIR: " + err.Error()) } + for key, value := range map[string]string{ + audit.EnvAudit: "1", + audit.EnvAuditDir: filepath.Join(tmpDir, "audit"), + audit.EnvRetentionDays: "1", + audit.EnvForwardURL: "", + audit.EnvForwardToken: "", + audit.EnvForwardRedact: "none", + audit.EnvAuditDebug: "", + } { + if err := os.Setenv(key, value); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + key + ": " + err.Error()) + } + } // Keep the process-wide audit sink outside per-test TempDir trees. Cobra // skips PersistentPostRunE on expected command errors, and Windows cannot // remove a TempDir while the audit lock is still open. @@ -63,6 +79,11 @@ func TestMain(m *testing.M) { StopAllStdioClients() CloseAuditSink() CloseFileLogger() - _ = os.RemoveAll(tmpDir) + if err := os.RemoveAll(tmpDir); err != nil { + fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err) + if code == 0 { + code = 1 + } + } os.Exit(code) } From bf105d3d298f38ac1fe3e2f06011996447a44a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 17:03:13 +0800 Subject: [PATCH 04/11] fix(logging): close replaced file logger --- internal/app/root.go | 27 +++++++++++++++++++++++---- internal/app/test_home_test.go | 16 ++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/app/root.go b/internal/app/root.go index d819ea1e..46954b5e 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -24,6 +24,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -722,7 +723,10 @@ func validateOptionalPath(flagName, path string) error { // fileLogger holds the package-level file logger for diagnostics. // It is initialized by configureLogLevel and closed by CloseFileLogger. -var fileLogger *logging.FileLogger +var ( + fileLoggerMu sync.Mutex + fileLogger *logging.FileLogger +) // configureLogLevel sets the global slog level based on --debug and --verbose flags // and initializes the file logger for diagnostics. @@ -744,14 +748,26 @@ func configureLogLevel(flags *GlobalFlags) { // Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level // regardless of stderr level. All slog calls are captured for diagnostics. - fileLogger = logging.Setup(defaultConfigDir()) - fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := replaceFileLogger(defaultConfigDir()) + fileHandler := slog.NewJSONHandler(logger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler))) } +func replaceFileLogger(configDir string) *logging.FileLogger { + fileLoggerMu.Lock() + defer fileLoggerMu.Unlock() + if fileLogger != nil { + _ = fileLogger.Close() + } + fileLogger = logging.Setup(configDir) + return fileLogger +} + // FileLoggerInstance returns the package-level file logger, or nil if not initialized. func FileLoggerInstance() *slog.Logger { + fileLoggerMu.Lock() + defer fileLoggerMu.Unlock() if fileLogger == nil { return nil } @@ -760,8 +776,11 @@ func FileLoggerInstance() *slog.Logger { // CloseFileLogger flushes and closes the file logger. func CloseFileLogger() { + fileLoggerMu.Lock() + defer fileLoggerMu.Unlock() if fileLogger != nil { - fileLogger.Close() + _ = fileLogger.Close() + fileLogger = nil } } diff --git a/internal/app/test_home_test.go b/internal/app/test_home_test.go index 23cbda2e..119e4e67 100644 --- a/internal/app/test_home_test.go +++ b/internal/app/test_home_test.go @@ -14,6 +14,7 @@ package app import ( + "os" "runtime" "testing" ) @@ -26,3 +27,18 @@ func setTestHome(t *testing.T, home string) { t.Setenv("USERPROFILE", home) } } + +func TestConfigureLogLevelReleasesPreviousFileLogger(t *testing.T) { + firstConfig := t.TempDir() + secondConfig := t.TempDir() + t.Cleanup(CloseFileLogger) + + t.Setenv("DWS_CONFIG_DIR", firstConfig) + configureLogLevel(&GlobalFlags{}) + t.Setenv("DWS_CONFIG_DIR", secondConfig) + configureLogLevel(&GlobalFlags{}) + + if err := os.RemoveAll(firstConfig); err != nil { + t.Fatalf("remove previous logger directory: %v", err) + } +} From ec29dc0e228fde3d06abd8a79aa55df30625602b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 17:16:53 +0800 Subject: [PATCH 05/11] fix(logging): make file logger close terminal --- internal/app/root.go | 16 +++++++--------- internal/app/test_home_test.go | 25 +++++++++++++++++++++++-- internal/logging/logger.go | 14 ++++++++++++++ internal/logging/logger_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/internal/app/root.go b/internal/app/root.go index 46954b5e..2e4ed661 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -748,20 +748,18 @@ func configureLogLevel(flags *GlobalFlags) { // Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level // regardless of stderr level. All slog calls are captured for diagnostics. - logger := replaceFileLogger(defaultConfigDir()) + logger := logging.Setup(defaultConfigDir()) fileHandler := slog.NewJSONHandler(logger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug}) + defaultLogger := slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)) - slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler))) -} - -func replaceFileLogger(configDir string) *logging.FileLogger { fileLoggerMu.Lock() defer fileLoggerMu.Unlock() - if fileLogger != nil { - _ = fileLogger.Close() + previous := fileLogger + fileLogger = logger + slog.SetDefault(defaultLogger) + if previous != nil { + _ = previous.Close() } - fileLogger = logging.Setup(configDir) - return fileLogger } // FileLoggerInstance returns the package-level file logger, or nil if not initialized. diff --git a/internal/app/test_home_test.go b/internal/app/test_home_test.go index 119e4e67..5a61785e 100644 --- a/internal/app/test_home_test.go +++ b/internal/app/test_home_test.go @@ -15,6 +15,7 @@ package app import ( "os" + "path/filepath" "runtime" "testing" ) @@ -35,10 +36,30 @@ func TestConfigureLogLevelReleasesPreviousFileLogger(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", firstConfig) configureLogLevel(&GlobalFlags{}) + firstLogger := FileLoggerInstance() t.Setenv("DWS_CONFIG_DIR", secondConfig) configureLogLevel(&GlobalFlags{}) - if err := os.RemoveAll(firstConfig); err != nil { - t.Fatalf("remove previous logger directory: %v", err) + firstLogPath := filepath.Join(firstConfig, "logs", "dws.log") + if err := os.Remove(firstLogPath); err != nil { + t.Fatalf("remove previous logger file: %v", err) + } + firstLogger.Info("late write to replaced logger") + if _, err := os.Stat(firstLogPath); !os.IsNotExist(err) { + t.Fatalf("replaced logger recreated its log file: %v", err) + } + + previousSameDirLogger := FileLoggerInstance() + configureLogLevel(&GlobalFlags{}) + currentLogger := FileLoggerInstance() + CloseFileLogger() + secondLogPath := filepath.Join(secondConfig, "logs", "dws.log") + if err := os.Remove(secondLogPath); err != nil { + t.Fatalf("remove current logger file: %v", err) + } + previousSameDirLogger.Info("late write to same-directory replaced logger") + currentLogger.Info("late write to closed current logger") + if _, err := os.Stat(secondLogPath); !os.IsNotExist(err) { + t.Fatalf("closed logger recreated same-directory log file: %v", err) } } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index eef07b25..ec82f7b4 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -120,6 +120,7 @@ type rotatingWriter struct { mu sync.Mutex file *os.File written int64 + closed bool } func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter { @@ -133,6 +134,9 @@ func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWrit func (w *rotatingWriter) open() error { w.mu.Lock() defer w.mu.Unlock() + if w.closed { + return os.ErrClosed + } f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm) if err != nil { @@ -151,6 +155,9 @@ func (w *rotatingWriter) open() error { func (w *rotatingWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() + if w.closed { + return 0, os.ErrClosed + } // Attempt to recover if file was lost (e.g. rotation failed due to full disk). if w.file == nil { @@ -175,6 +182,9 @@ func (w *rotatingWriter) Write(p []byte) (int, error) { // reopenLocked tries to reopen the log file. Caller must hold w.mu. func (w *rotatingWriter) reopenLocked() error { + if w.closed { + return os.ErrClosed + } f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm) if err != nil { return err @@ -197,6 +207,10 @@ func (w *rotatingWriter) close() error { w.mu.Lock() defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true if w.file == nil { return nil } diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 18e05489..326061e4 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -14,6 +14,7 @@ package logging import ( + "errors" "os" "path/filepath" "strings" @@ -121,3 +122,30 @@ func TestCloseOnNilLogger(t *testing.T) { t.Errorf("Close on nil should not error: %v", err) } } + +func TestFileLoggerCloseIsTerminal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + fl := Setup(dir) + logPath := filepath.Join(dir, logSubDir, logFileName) + fl.Logger.Info("before close") + if err := fl.Close(); err != nil { + t.Fatalf("close logger: %v", err) + } + if err := os.Remove(logPath); err != nil { + t.Fatalf("remove closed log file: %v", err) + } + + n, err := fl.Writer().Write([]byte("late write")) + if n != 0 || !errors.Is(err, os.ErrClosed) { + t.Fatalf("write after close = (%d, %v), want (0, os.ErrClosed)", n, err) + } + fl.Logger.Info("late structured write") + if _, err := os.Stat(logPath); !os.IsNotExist(err) { + t.Fatalf("closed logger recreated log file: %v", err) + } + if err := fl.Close(); err != nil { + t.Fatalf("second close: %v", err) + } +} From 1822b822325a632639a65ce1d31789434b3c1a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 15 Jul 2026 17:23:12 +0800 Subject: [PATCH 06/11] test(logging): clarify terminal replacement coverage --- internal/app/test_home_test.go | 2 +- internal/logging/logger.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/app/test_home_test.go b/internal/app/test_home_test.go index 5a61785e..50d6fb8a 100644 --- a/internal/app/test_home_test.go +++ b/internal/app/test_home_test.go @@ -29,7 +29,7 @@ func setTestHome(t *testing.T, home string) { } } -func TestConfigureLogLevelReleasesPreviousFileLogger(t *testing.T) { +func TestConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) { firstConfig := t.TempDir() secondConfig := t.TempDir() t.Cleanup(CloseFileLogger) diff --git a/internal/logging/logger.go b/internal/logging/logger.go index ec82f7b4..b67c50b7 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -77,7 +77,8 @@ func (fl *FileLogger) Writer() io.Writer { return fl.writer } -// Close flushes and closes the underlying log file. +// Close flushes and permanently closes the underlying log file. Later writes +// fail with os.ErrClosed instead of reopening the file. func (fl *FileLogger) Close() error { if fl == nil || fl.writer == nil { return nil From 74fa24ee1e6c4826dbaeb71a12dfe23d2d5769e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 10:13:41 +0800 Subject: [PATCH 07/11] fix(auth): reject unsupported Windows portable import --- internal/app/auth_command.go | 7 +++ internal/app/auth_command_test.go | 73 +++++++++++++++++++++++ internal/auth/portable_store.go | 17 ++++++ internal/auth/portable_store_test.go | 89 ++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index df76f520..f50082cc 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -699,6 +699,10 @@ func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command { } func newAuthImportCommand() *cobra.Command { + return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError) +} + +func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command { cmd := &cobra.Command{ Use: "import", Short: "导入可迁移认证包", @@ -727,6 +731,9 @@ func newAuthImportCommand() *cobra.Command { if err != nil { return apperrors.NewInternal("failed to read --force") } + if err := supportError(); err != nil { + return apperrors.NewValidation(err.Error()) + } configDir := defaultConfigDir() if !force && authpkg.PortableAuthTargetPopulated(configDir) { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 8af273e9..02d6b607 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -149,6 +149,79 @@ func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { } } +func TestAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, ".dws") + keychainDir := filepath.Join(root, "keychain") + t.Setenv("DWS_CONFIG_DIR", configDir) + t.Setenv(keychain.StorageDirEnv, keychainDir) + + importCmd := newAuthImportCommandWithSupport(func() error { + return errors.New("portable auth import is unavailable for the test backend") + }) + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&bytes.Buffer{}) + importCmd.SetArgs([]string{"--input", filepath.Join(root, "missing-bundle.tar.gz")}) + + err := importCmd.Execute() + if err == nil { + t.Fatal("auth import should reject an unsupported credential backend") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), "test backend") { + t.Fatalf("error = %v, want backend-specific reason", err) + } + for _, path := range []string{configDir, keychainDir} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("unsupported import touched %s: stat error = %v", path, statErr) + } + } +} + +func TestAuthImportRejectsWindowsDPAPIBackend(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows DPAPI contract requires a native Windows runner") + } + t.Cleanup(CloseFileLogger) + + root := t.TempDir() + configDir := filepath.Join(root, ".dws") + keychainDir := filepath.Join(root, "keychain") + inputPath := filepath.Join(root, "bundle.tar.gz") + if err := os.WriteFile(inputPath, []byte("the capability guard must run before this input is read"), 0o600); err != nil { + t.Fatalf("write input sentinel error = %v", err) + } + t.Setenv("DWS_CONFIG_DIR", configDir) + t.Setenv(keychain.StorageDirEnv, keychainDir) + + importCmd := NewRootCommand() + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&bytes.Buffer{}) + importCmd.SetArgs([]string{"auth", "import", "--input", inputPath}) + + err := importCmd.Execute() + if err == nil { + t.Fatal("auth import should reject the Windows DPAPI backend") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + for _, want := range []string{"Windows", "DPAPI", "HKCU"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want substring %q", err, want) + } + } + for _, path := range []string{configDir, keychainDir} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr) + } + } +} + func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index e13f64dc..6d2c8f30 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -63,6 +63,20 @@ func portableExportSupportError(goos, disableKeychain string) error { return nil } +// PortableImportSupportError explains why the current credential backend +// cannot consume a portable auth bundle. A nil error means import is +// supported. +func PortableImportSupportError() error { + return portableImportSupportError(runtime.GOOS) +} + +func portableImportSupportError(goos string) error { + if goos == "windows" { + return fmt.Errorf("portable auth import is not supported on Windows because file-DEK credentials cannot be represented as DPAPI-protected HKCU Registry values; a portable conversion is not implemented") + } + return nil +} + // PortableAuthTargetPopulated reports whether local auth files would be // overwritten by a portable import. func PortableAuthTargetPopulated(configDir string) bool { @@ -165,6 +179,9 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo if r == nil { return PortableImportReport{}, fmt.Errorf("missing input reader") } + if err := PortableImportSupportError(); err != nil { + return PortableImportReport{}, err + } gz, err := gzip.NewReader(r) if err != nil { return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err) diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go index f5ace527..e2116ee0 100644 --- a/internal/auth/portable_store_test.go +++ b/internal/auth/portable_store_test.go @@ -14,8 +14,11 @@ package auth import ( + "archive/tar" "bytes" + "compress/gzip" "os" + "path" "path/filepath" "runtime" "strings" @@ -86,6 +89,54 @@ func TestPortableExportSupportError(t *testing.T) { } } +func TestPortableImportSupportError(t *testing.T) { + tests := []struct { + name string + goos string + wantError string + }{ + {name: "windows dpapi", goos: "windows", wantError: "DPAPI"}, + {name: "darwin", goos: "darwin"}, + {name: "linux", goos: "linux"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := portableImportSupportError(test.goos) + if test.wantError == "" { + if err != nil { + t.Fatalf("portableImportSupportError() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("portableImportSupportError() error = %v, want substring %q", err, test.wantError) + } + }) + } +} + +func TestImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows DPAPI contract requires a native Windows runner") + } + + root := t.TempDir() + configDir := filepath.Join(root, ".dws") + keychainDir := filepath.Join(root, "keychain") + t.Setenv(keychain.StorageDirEnv, keychainDir) + bundle := validPortableAuthBundleForTest(t) + + if _, err := ImportPortableAuthBundle(configDir, bytes.NewReader(bundle)); err == nil || !strings.Contains(err.Error(), "DPAPI") { + t.Fatalf("ImportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err) + } + for _, path := range []string{configDir, keychainDir} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, err) + } + } +} + func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") keychainRoot := filepath.Join(t.TempDir(), "empty-keychain") @@ -266,3 +317,41 @@ func requirePortableFileBackend(t *testing.T) { t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage") } } + +func validPortableAuthBundleForTest(t *testing.T) []byte { + t.Helper() + + var bundle bytes.Buffer + gz := gzip.NewWriter(&bundle) + tw := tar.NewWriter(gz) + manifest := portableAuthBundleManifest{ + Version: 1, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + OS: "linux", + KeychainService: keychain.Service, + ConfigFiles: []string{"app.json"}, + } + if err := writePortableManifest(tw, manifest); err != nil { + t.Fatalf("writePortableManifest() error = %v", err) + } + entries := map[string]string{ + path.Join("keychain", keychain.Service, keychain.AccountToken+".enc"): "encrypted-token", + "config/app.json": "{}", + } + for name, content := range entries { + hdr := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("WriteHeader(%s) error = %v", name, err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("Write(%s) error = %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar.Close() error = %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip.Close() error = %v", err) + } + return bundle.Bytes() +} From e00019039c1c8621b1956cb5e9c9b75e079257f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 10:36:51 +0800 Subject: [PATCH 08/11] fix(auth): preserve force validation before support guard --- internal/app/auth_command.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index f50082cc..c58af9e1 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -731,14 +731,13 @@ func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command { if err != nil { return apperrors.NewInternal("failed to read --force") } - if err := supportError(); err != nil { - return apperrors.NewValidation(err.Error()) - } - configDir := defaultConfigDir() if !force && authpkg.PortableAuthTargetPopulated(configDir) { return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖") } + if err := supportError(); err != nil { + return apperrors.NewValidation(err.Error()) + } payload, err := os.ReadFile(input) if err != nil { From 7dc5b6f2ed3a17b000c834732a17fa18a1cd9d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 12:12:26 +0800 Subject: [PATCH 09/11] test(coverage): exercise portable auth platform guards --- .github/workflows/ci.yml | 4 ++-- internal/app/auth_command_test.go | 10 +++++----- internal/app/test_home_test.go | 2 +- internal/auth/portable_store_test.go | 8 ++++---- internal/logging/logger_test.go | 11 ++++++++++- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4166bab3..71a1263e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,8 +103,8 @@ jobs: - name: Test Windows auth and DPAPI packages run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth - - name: Test Windows auth migration and export diagnostics - run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend)' + - name: Test Windows auth migration and portable auth diagnostics + run: go test -v -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)' coverage-darwin: name: Coverage (macOS) diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 02d6b607..fb8b89b8 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -102,7 +102,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) { } } -func TestAuthExportUnsupportedBackendIsValidationError(t *testing.T) { +func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(t *testing.T) { exportCmd := newAuthExportCommandWithSupport(func() error { return errors.New("portable auth export is unavailable for the test backend") }) @@ -123,7 +123,7 @@ func TestAuthExportUnsupportedBackendIsValidationError(t *testing.T) { } } -func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { +func TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } @@ -149,7 +149,7 @@ func TestAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { } } -func TestAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) { +func TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) { root := t.TempDir() configDir := filepath.Join(root, ".dws") keychainDir := filepath.Join(root, "keychain") @@ -181,7 +181,7 @@ func TestAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *test } } -func TestAuthImportRejectsWindowsDPAPIBackend(t *testing.T) { +func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } @@ -222,7 +222,7 @@ func TestAuthImportRejectsWindowsDPAPIBackend(t *testing.T) { } } -func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { +func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() t.Cleanup(CloseFileLogger) diff --git a/internal/app/test_home_test.go b/internal/app/test_home_test.go index 50d6fb8a..51a534e7 100644 --- a/internal/app/test_home_test.go +++ b/internal/app/test_home_test.go @@ -29,7 +29,7 @@ func setTestHome(t *testing.T, home string) { } } -func TestConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) { +func TestCrossPlatformCoverageConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) { firstConfig := t.TempDir() secondConfig := t.TempDir() t.Cleanup(CloseFileLogger) diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go index e2116ee0..9fc98ec5 100644 --- a/internal/auth/portable_store_test.go +++ b/internal/auth/portable_store_test.go @@ -28,7 +28,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) -func TestPortableExportSupported(t *testing.T) { +func TestCrossPlatformCoveragePortableExportSupported(t *testing.T) { switch runtime.GOOS { case "windows": if PortableExportSupported() { @@ -60,7 +60,7 @@ func TestPortableExportSupported(t *testing.T) { } } -func TestPortableExportSupportError(t *testing.T) { +func TestCrossPlatformCoveragePortableExportSupportError(t *testing.T) { tests := []struct { name string goos string @@ -89,7 +89,7 @@ func TestPortableExportSupportError(t *testing.T) { } } -func TestPortableImportSupportError(t *testing.T) { +func TestCrossPlatformCoveragePortableImportSupportError(t *testing.T) { tests := []struct { name string goos string @@ -116,7 +116,7 @@ func TestPortableImportSupportError(t *testing.T) { } } -func TestImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) { +func TestCrossPlatformCoverageImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 326061e4..f5bf7dfe 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -123,7 +123,7 @@ func TestCloseOnNilLogger(t *testing.T) { } } -func TestFileLoggerCloseIsTerminal(t *testing.T) { +func TestCrossPlatformCoverageFileLoggerCloseIsTerminal(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -136,6 +136,15 @@ func TestFileLoggerCloseIsTerminal(t *testing.T) { if err := os.Remove(logPath); err != nil { t.Fatalf("remove closed log file: %v", err) } + if err := fl.writer.open(); !errors.Is(err, os.ErrClosed) { + t.Fatalf("open after close = %v, want os.ErrClosed", err) + } + fl.writer.mu.Lock() + err := fl.writer.reopenLocked() + fl.writer.mu.Unlock() + if !errors.Is(err, os.ErrClosed) { + t.Fatalf("reopen after close = %v, want os.ErrClosed", err) + } n, err := fl.Writer().Write([]byte("late write")) if n != 0 || !errors.Is(err, os.ErrClosed) { From c79c5dfffe4cec3a30e4e7828f47d8b623d0f5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 12:21:27 +0800 Subject: [PATCH 10/11] test(windows): isolate portable import side effects --- internal/app/auth_command_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index fb8b89b8..a0065247 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -185,9 +185,12 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } - t.Cleanup(CloseFileLogger) root := t.TempDir() + // NewRootCommand initializes the normal CLI file logger below configDir. + // Register its cleanup after TempDir so the Windows handle is closed before + // testing removes the temporary directory. + t.Cleanup(CloseFileLogger) configDir := filepath.Join(root, ".dws") keychainDir := filepath.Join(root, "keychain") inputPath := filepath.Join(root, "bundle.tar.gz") @@ -215,7 +218,14 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) t.Fatalf("error = %v, want substring %q", err, want) } } - for _, path := range []string{configDir, keychainDir} { + // The root command may create configDir/logs as part of normal CLI startup. + // The capability guard must still run before any auth state is imported. + for _, path := range []string{ + keychainDir, + authpkg.ProfilesPath(configDir), + filepath.Join(configDir, "app.json"), + filepath.Join(configDir, "token.json"), + } { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr) } From 07d2e4597e7b98b1c17065d38fb1fe8a3f53dd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 13:55:58 +0800 Subject: [PATCH 11/11] test(auth): keep portable fixtures platform-neutral --- internal/auth/coverage_edges_test.go | 6 ++++++ internal/auth/remaining_coverage_test.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/internal/auth/coverage_edges_test.go b/internal/auth/coverage_edges_test.go index e5825415..1fa26b34 100644 --- a/internal/auth/coverage_edges_test.go +++ b/internal/auth/coverage_edges_test.go @@ -1234,6 +1234,12 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) { }) t.Run("import validation", func(t *testing.T) { + oldGOOS := portableRuntimeGOOS + t.Cleanup(func() { + portableRuntimeGOOS = oldGOOS + }) + portableRuntimeGOOS = func() string { return "linux" } + t.Setenv(keychain.StorageDirEnv, t.TempDir()) if _, err := ImportPortableAuthBundle(t.TempDir(), nil); err == nil { t.Fatal("nil portable reader succeeded") diff --git a/internal/auth/remaining_coverage_test.go b/internal/auth/remaining_coverage_test.go index 5044bfbc..828d635f 100644 --- a/internal/auth/remaining_coverage_test.go +++ b/internal/auth/remaining_coverage_test.go @@ -91,6 +91,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing } func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) { + origGOOS := portableRuntimeGOOS origStat := portableStat origConfigFiles := portableConfigFilesForExport origManifest := portableWriteManifest @@ -99,6 +100,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) { origKeychainGet := authKeychainGet origKeychainExists := authKeychainExists t.Cleanup(func() { + portableRuntimeGOOS = origGOOS portableStat = origStat portableConfigFilesForExport = origConfigFiles portableWriteManifest = origManifest @@ -107,6 +109,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) { authKeychainGet = origKeychainGet authKeychainExists = origKeychainExists }) + portableRuntimeGOOS = func() string { return "linux" } t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv(keychain.DisableKeychainEnv, "1")