Compare commits

..
Author SHA1 Message Date
修雨 da7b490e08 ci: include app subpackages in race shard 2026-07-16 17:05:43 +08:00
修雨 e2ab422787 ci: parallelize PR test and coverage jobs 2026-07-16 16:58:10 +08:00
修雨 4d05ea4fc1 Merge pull request #628 from PeterGuy326/codex/fix-windows-portable-export-contract
fix(auth): reject unsupported Windows portable export
2026-07-16 16:22:16 +08:00
修雨 e3bbb33c18 Merge remote-tracking branch 'origin/main' into pr628-merge
# Conflicts:
#	CHANGELOG.md
2026-07-16 16:19:31 +08:00
修雨 950de23e74 Merge branch 'main' into codex/fix-windows-portable-export-contract 2026-07-16 14:31:53 +08:00
修雨 07d2e4597e test(auth): keep portable fixtures platform-neutral 2026-07-16 13:55:58 +08:00
修雨 57d753cd1d Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716
# Conflicts:
#	CHANGELOG.md
#	internal/app/auth_command.go
#	internal/app/auth_command_test.go
#	internal/app/config_test.go
#	internal/app/root.go
#	internal/app/skill_setup_test.go
#	internal/app/timing_test.go
#	internal/auth/portable_store.go
#	internal/logging/logger.go
2026-07-16 13:42:38 +08:00
修雨 d202d58963 Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716 2026-07-16 12:40:55 +08:00
修雨 c79c5dfffe test(windows): isolate portable import side effects 2026-07-16 12:21:27 +08:00
修雨 681f2db87a Merge origin/main into codex/fix-windows-portable-export-contract 2026-07-16 12:12:41 +08:00
修雨 7dc5b6f2ed test(coverage): exercise portable auth platform guards 2026-07-16 12:12:26 +08:00
修雨 fb4e6a0fdb Merge origin/main into codex/fix-windows-portable-export-contract 2026-07-16 12:05:22 +08:00
修雨 88d82f201f Merge origin/main into codex/fix-windows-portable-export-contract
# Conflicts:
#	CHANGELOG.md
2026-07-16 11:37:36 +08:00
修雨 e00019039c fix(auth): preserve force validation before support guard 2026-07-16 10:36:51 +08:00
修雨 74fa24ee1e fix(auth): reject unsupported Windows portable import 2026-07-16 10:13:41 +08:00
修雨 1822b82232 test(logging): clarify terminal replacement coverage 2026-07-15 17:23:12 +08:00
修雨 ec29dc0e22 fix(logging): make file logger close terminal 2026-07-15 17:16:53 +08:00
修雨 bf105d3d29 fix(logging): close replaced file logger 2026-07-15 17:03:13 +08:00
修雨 48681eb41c test: isolate app audit environment 2026-07-15 16:53:55 +08:00
修雨 5e41b8a6e8 test(windows): make app coverage portable 2026-07-15 16:45:57 +08:00
修雨 296e9a73f0 fix(auth): reject unsupported Windows portable export 2026-07-15 16:04:17 +08:00
19 changed files with 928 additions and 83 deletions
+248 -29
View File
@@ -9,6 +9,10 @@ on:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
@@ -54,10 +58,18 @@ jobs:
- name: Check GitHub Actions workflows
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
test:
name: Test
test-race:
name: "Test (race: ${{ matrix.shard }})"
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard:
- app
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -71,19 +83,85 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
if: ${{ matrix.shard == 'remaining' }}
run: make build
- name: Test with Race Detection
# The registry-first final-delivery gate validates all public commands
# and the complete generated Catalog under the race detector. Keep the
# package timeout aligned with the macOS race job so the Linux runner's
# five-minute default does not expire while that gate is still making
# progress.
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
- name: Test shard with Race Detection
env:
TEST_SHARD: ${{ matrix.shard }}
run: |
case "$TEST_SHARD" in
app)
packages=(./internal/app/...)
;;
generators)
packages=(./internal/generator/...)
;;
helpers)
packages=(./internal/helpers/...)
;;
remaining)
mapfile -t packages < <(
go list ./cmd/... ./internal/... |
grep -Ev '/internal/(app|generator|helpers)(/|$)'
)
;;
*)
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
exit 1
;;
esac
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
test-release-scripts:
name: Test (release scripts)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
test:
name: Test
needs:
- test-race
- test-release-scripts
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify test shards
env:
RACE_RESULT: ${{ needs.test-race.result }}
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
run: |
failed=0
for shard in \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT"
do
name="${shard%%:*}"
result="${shard#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
test-darwin:
name: Test (macOS auth/keychain)
runs-on: macos-latest
@@ -119,8 +197,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 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)
@@ -208,10 +286,80 @@ jobs:
name: coverage-windows
path: coverage-windows.txt
coverage:
name: Coverage
coverage-current:
name: Coverage (current)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Run current unit tests with coverage
run: |
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go tool cover -func=coverage.txt
- name: Upload current coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-current-profile
path: coverage.txt
retention-days: 1
coverage-supporting:
name: Coverage (supporting)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run policy and shortcut coverage
run: |
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
- name: Upload supporting coverage profiles
uses: actions/upload-artifact@v4
with:
name: coverage-supporting-profiles
path: |
coverage-policy.txt
coverage-shortcut.txt
retention-days: 1
coverage-baseline:
name: Coverage (baseline)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -227,9 +375,6 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -247,18 +392,9 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run current and baseline unit tests with coverage
- name: Run baseline unit tests with coverage
run: |
set -eu
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
rmdir "$base_worktree"
cleanup() {
@@ -274,7 +410,90 @@ jobs:
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
go tool cover -func=coverage.txt
- name: Upload baseline coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-baseline-profile
path: coverage-base.txt
retention-days: 1
coverage:
name: Coverage
needs:
- coverage-current
- coverage-supporting
- coverage-baseline
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Verify coverage profile jobs
env:
CURRENT_RESULT: ${{ needs.coverage-current.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
run: |
failed=0
for profile in \
"current:$CURRENT_RESULT" \
"supporting:$SUPPORTING_RESULT" \
"baseline:$BASELINE_RESULT"
do
name="${profile%%:*}"
result="${profile#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
path: .
- name: Download supporting coverage profiles
uses: actions/download-artifact@v4
with:
name: coverage-supporting-profiles
path: .
- name: Download baseline coverage profile
uses: actions/download-artifact@v4
with:
name: coverage-baseline-profile
path: .
- name: Enforce coverage gate
env:
+1
View File
@@ -26,6 +26,7 @@ This beta validates the accumulated post-v1.0.52 command surface, release automa
- **PAT organization-policy denials stop immediately** — `PAT_ORG_POLICY_DENIED` now remains terminal even if a backend also returns `flowId`, authorization URLs, or client credentials; the CLI does not mutate process credentials, open a browser, poll, or retry until an organization administrator changes the policy.
- **Sheet and task invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while task completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
- **Windows portable-auth contract** — `dws auth export` and `dws auth import` now fail early without reading credentials, bundles, or writing files instead of claiming portable-bundle support for DPAPI-protected HKCU Registry credentials.
- **Concurrent credential writes and reentrant CLI execution** — secure-token writers now use isolated, exclusive temporary files before atomic replacement so concurrent processes cannot remove each other's in-flight data, and repeated in-process CLI runs close the previous file logger before replacing it instead of retaining the prior log-file handle.
## [1.0.52] - 2026-07-14
+3
View File
@@ -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 and import are intentionally rejected before credentials or
bundles are read: Windows stores credentials as DPAPI-protected HKCU Registry
values, and the current file-DEK bundle has no safe DPAPI-to-portable conversion.
</details>
+20 -6
View File
@@ -23,6 +23,11 @@ The repository defines five focused checks in addition to its existing CI:
variance to avoid failing unchanged code on test-path noise. Set
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
the overall target fail closed as well.
CI generates the candidate, supporting, and merge-base profiles on three
independent runners, then downloads all profiles into the aggregate
`Coverage` job and applies the same fail-closed gate. The split changes only
scheduling: the tested packages, profile contents, merge-base comparison,
and final coverage thresholds remain unchanged.
- **CLI Smoke** builds the release binary, reads the root command list from the
structured Interface contract, and renders offline help for every public
top-level command. It rejects Cobra's unknown-command root-help fallback and
@@ -53,9 +58,10 @@ make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
```
`make coverage-gate` is the enforcement step, not a profile generator. It
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
expects the candidate, policy, shortcut, and merge-base profiles
(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and
`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local
checkout can reproduce the Linux/overall CI gate sequentially with:
```sh
base_ref=$(git merge-base HEAD origin/main)
@@ -65,17 +71,25 @@ rmdir "$base_worktree"
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
trap cleanup EXIT HUP INT TERM
go test -count=1 -coverprofile=coverage.txt -covermode=atomic \
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
git worktree add --detach "$base_worktree" "$base_ref"
(
cd "$base_worktree"
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
go test -count=1 -p 1 \
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
make coverage-gate BASE_REF="$base_ref"
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
make coverage-gate BASE_REF="$base_ref"
```
The native-platform target likewise expects `PROFILE` to have already been
+14 -1
View File
@@ -666,6 +666,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: "导出可迁移认证包",
@@ -690,6 +694,9 @@ func newAuthExportCommand() *cobra.Command {
if !asBase64 && output == "" {
return apperrors.NewValidation("--output is required unless --base64 is used")
}
if err := supportError(); err != nil {
return apperrors.NewValidation(err.Error())
}
if !authPortableExportSupported() {
return apperrors.NewValidation(fmt.Sprintf(
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
@@ -733,6 +740,10 @@ func newAuthExportCommand() *cobra.Command {
}
func newAuthImportCommand() *cobra.Command {
return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError)
}
func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command {
cmd := &cobra.Command{
Use: "import",
Short: "导入可迁移认证包",
@@ -761,7 +772,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 && authPortableTargetPopulated(configDir) {
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
@@ -618,7 +618,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
authDeleteAppConfig = oldDeleteConfig
})
export := newAuthExportCommand()
export := newAuthExportCommandWithSupport(func() error { return nil })
badString := &cobra.Command{}
badString.Flags().Bool("output", false, "")
if err := export.RunE(badString, nil); err == nil {
@@ -658,7 +658,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
if err := export.RunE(export, nil); err != nil {
t.Fatal(err)
}
export = newAuthExportCommand()
export = newAuthExportCommandWithSupport(func() error { return nil })
_, out, _ := authCoverageRoot(export, "table", false)
_ = export.Flags().Set("base64", "true")
export.SetOut(&appFailWriter{err: errors.New("stdout")})
@@ -676,7 +676,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
t.Fatal(err)
}
importCmd := newAuthImportCommand()
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
badInput := &cobra.Command{}
badInput.Flags().Bool("input", false, "")
if err := importCmd.RunE(badInput, nil); err == nil {
+259 -5
View File
@@ -23,6 +23,7 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -58,7 +59,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
return err
}
exportCmd := newAuthExportCommand()
exportCmd := newAuthExportCommandWithSupport(func() error { return nil })
var exported bytes.Buffer
exportCmd.SetOut(&exported)
exportCmd.SetErr(&bytes.Buffer{})
@@ -84,7 +85,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
return authpkg.PortableImportReport{}, err
}
importCmd := newAuthImportCommand()
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", inputPath, "--base64"})
@@ -96,6 +97,260 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
}
}
func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(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 TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(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{})
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 TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(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 TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
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")
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)
}
}
// 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)
}
}
}
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackendWithPopulatedCredentialBeforeRead(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
previous, previousErr := authpkg.LoadTokenDataKeychain()
if previousErr != nil && !errors.Is(previousErr, authpkg.ErrTokenDataNotFound) {
t.Fatalf("capture existing Windows credential: %v", previousErr)
}
hadPrevious := previousErr == nil
t.Cleanup(func() {
if hadPrevious {
_ = authpkg.SaveTokenDataKeychain(previous)
} else {
_ = authpkg.DeleteTokenDataKeychain()
}
})
want := &authpkg.TokenData{
AccessToken: "windows-existing-access",
RefreshToken: "windows-existing-refresh",
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "windows-existing-corp",
}
if err := authpkg.SaveTokenDataKeychain(want); err != nil {
t.Fatalf("seed cleanup-scoped Windows DPAPI credential: %v", err)
}
originalTarget := authPortableTargetPopulated
originalRead := authReadFile
targetChecks := 0
bundleReads := 0
authPortableTargetPopulated = func(configDir string) bool {
targetChecks++
return originalTarget(configDir)
}
authReadFile = func(path string) ([]byte, error) {
bundleReads++
return originalRead(path)
}
t.Cleanup(func() {
authPortableTargetPopulated = originalTarget
authReadFile = originalRead
})
root := t.TempDir()
inputPath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(inputPath, []byte("unsupported Windows import must not read this bundle"), 0o600); err != nil {
t.Fatalf("write bundle sentinel: %v", err)
}
t.Setenv("DWS_CONFIG_DIR", filepath.Join(root, ".dws"))
importCmd := newAuthImportCommand()
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", inputPath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import should reject a populated 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 _, required := range []string{"Windows", "DPAPI", "HKCU"} {
if !strings.Contains(err.Error(), required) {
t.Fatalf("error = %v, want substring %q", err, required)
}
}
if strings.Contains(err.Error(), "--force") {
t.Fatalf("unsupported Windows import suggested impossible --force remediation: %v", err)
}
if targetChecks != 0 || bundleReads != 0 {
t.Fatalf("unsupported Windows import inspected credentials/bundle: target_checks=%d bundle_reads=%d", targetChecks, bundleReads)
}
got, err := authpkg.LoadTokenDataKeychain()
if err != nil {
t.Fatalf("reload Windows DPAPI credential after rejection: %v", err)
}
if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || got.CorpID != want.CorpID {
t.Fatalf("Windows auth state changed after rejected import: got=%#v want=%#v", got, want)
}
}
func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(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)
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "existing",
RefreshToken: "existing-refresh",
RefreshExpAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
bundlePath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
var stderr bytes.Buffer
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetArgs([]string{"--input", bundlePath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import without --force should fail when auth exists")
}
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(), "--force") {
t.Fatalf("error = %v, want --force hint", err)
}
}
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
originalTarget := authPortableTargetPopulated
authPortableTargetPopulated = func(string) bool { return true }
@@ -110,10 +365,9 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := newAuthImportCommand()
var stderr bytes.Buffer
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", bundlePath})
err := importCmd.Execute()
if err == nil {
+22 -9
View File
@@ -24,6 +24,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -748,7 +749,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.
@@ -770,18 +774,24 @@ 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.
// A previously closed rotating writer may have been reopened by slog calls
// made while constructing another root command, so close it before replacing
// the package pointer during reentrant executions.
CloseFileLogger()
fileLogger = logging.Setup(defaultConfigDir())
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
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)))
fileLoggerMu.Lock()
defer fileLoggerMu.Unlock()
previous := fileLogger
fileLogger = logger
slog.SetDefault(defaultLogger)
if previous != nil {
_ = previous.Close()
}
}
// 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
}
@@ -790,8 +800,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
}
}
@@ -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{
+2
View File
@@ -369,6 +369,7 @@ func TestSkillInstallCommandValidation(t *testing.T) {
func TestSkillInstallInvalidTarget(t *testing.T) {
tempDir := t.TempDir()
t.Cleanup(CloseFileLogger)
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
@@ -392,6 +393,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)
t.Cleanup(CloseFileLogger)
+7 -8
View File
@@ -88,7 +88,7 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
// Isolate HOME so the ~/.dws/skills/<mode>/ 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")
@@ -110,8 +110,9 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if !strings.Contains(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])
}
}
@@ -133,11 +134,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(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])
}
}
+65
View File
@@ -0,0 +1,65 @@
// 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 (
"os"
"path/filepath"
"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)
}
}
func TestCrossPlatformCoverageConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) {
firstConfig := t.TempDir()
secondConfig := t.TempDir()
t.Cleanup(CloseFileLogger)
t.Setenv("DWS_CONFIG_DIR", firstConfig)
configureLogLevel(&GlobalFlags{})
firstLogger := FileLoggerInstance()
t.Setenv("DWS_CONFIG_DIR", secondConfig)
configureLogLevel(&GlobalFlags{})
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)
}
}
+34 -1
View File
@@ -14,9 +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"
)
@@ -49,8 +52,38 @@ 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())
}
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.
setupAuditSink()
openBrowserFunc = func(string) error { return nil }
code := m.Run()
_ = os.RemoveAll(tmpDir)
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
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)
}
+6
View File
@@ -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")
+41 -7
View File
@@ -69,13 +69,44 @@ 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 portableRuntimeGOOS() != "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(portableRuntimeGOOS(), 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
}
// 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(portableRuntimeGOOS())
}
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
@@ -128,8 +159,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 := portableStat(keychainDir); err != nil {
@@ -180,6 +211,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)
+144 -10
View File
@@ -14,30 +14,126 @@
package auth
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestPortableExportSupported(t *testing.T) {
if runtime.GOOS != "darwin" {
if !PortableExportSupported() {
t.Fatal("PortableExportSupported() should be true on non-darwin")
func TestCrossPlatformCoveragePortableExportSupported(t *testing.T) {
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 TestCrossPlatformCoveragePortableExportSupportError(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)
}
})
}
}
func TestCrossPlatformCoveragePortableImportSupportError(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 TestCrossPlatformCoverageImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(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)
}
}
}
@@ -221,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()
}
+3
View File
@@ -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")
+16 -1
View File
@@ -79,7 +79,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
@@ -122,6 +123,7 @@ type rotatingWriter struct {
mu sync.Mutex
file *os.File
written int64
closed bool
}
func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter {
@@ -135,6 +137,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 := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
@@ -153,6 +158,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 {
@@ -177,6 +185,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 := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
@@ -199,6 +210,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
}
+37
View File
@@ -14,6 +14,7 @@
package logging
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -121,3 +122,39 @@ func TestCloseOnNilLogger(t *testing.T) {
t.Errorf("Close on nil should not error: %v", err)
}
}
func TestCrossPlatformCoverageFileLoggerCloseIsTerminal(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)
}
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) {
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)
}
}