Compare commits

..
Author SHA1 Message Date
修雨 03796388c3 test(shortcut): cover platform compatibility paths 2026-07-17 11:03:09 +08:00
修雨 833d0cc05e Merge main into bugfix-im-shortcut-ai-tag
Resolve the shortcut catalog constraint migration and preserve both fake caller response modes.
2026-07-17 10:37:55 +08:00
修雨 bdc480cf49 Merge pull request #647 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.53-beta.2
chore: update Homebrew beta formula for v1.0.53-beta.2
2026-07-17 10:01:30 +08:00
修雨 f8e1be5970 fix(homebrew): use sealed GitHub beta checksums 2026-07-17 09:38:10 +08:00
Dennis 8d1ccd1b98 fit chat shortcut aliases 2026-07-17 09:36:28 +08:00
Dennis c84b5d05f4 fix chat search shortcut keyword alias 2026-07-17 09:29:06 +08:00
修雨 669518682c chore: update beta formula for v1.0.53-beta.2 2026-07-16 19:05:16 +08:00
修雨 642e676f79 Merge pull request #642 from DingTalk-Real-AI/codex/parallelize-ci-jobs
ci: parallelize PR test and coverage jobs
2026-07-16 18:57:29 +08:00
修雨 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
Dennis 0d81f061d8 fix shortcut IM AI message tag 2026-07-16 15:39:39 +08:00
xuan 1c09115bd6 Merge pull request #645 from PeterGuy326/codex/fix-delivered-stable-recovery-proof
fix(release): recognize reviewed stable recovery
2026-07-16 15:39:39 +08:00
修雨 a0a4b5dfbe fix(release): recognize reviewed stable recovery 2026-07-16 15:36:23 +08:00
修雨 3f653d9da0 Merge pull request #644 from DingTalk-Real-AI/codex/fix-v1.0.53-beta.2-changelog-gate
docs(changelog): unblock v1.0.53-beta.2 preflight
2026-07-16 15:19:27 +08:00
修雨 6e0917a3ed docs(changelog): avoid beta placeholder false positive 2026-07-16 15:03:24 +08:00
修雨 b5f431ba51 Merge pull request #641 from DingTalk-Real-AI/codex/changelog-v1.0.53-beta.2
docs(changelog): prepare v1.0.53-beta.2
2026-07-16 14:49:42 +08:00
修雨 950de23e74 Merge branch 'main' into codex/fix-windows-portable-export-contract 2026-07-16 14:31:53 +08:00
修雨 0108b1ca28 docs(changelog): prepare v1.0.53-beta.2 2026-07-16 14:27:52 +08:00
修雨 34aad4596c Merge pull request #638 from DingTalk-Real-AI/codex/feat-contact-enterprise-onboarding
feat(contact): add enterprise onboarding commands
2026-07-16 14:15:37 +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
修雨 9e12da0219 Merge remote-tracking branch 'origin/main' into codex/feat-contact-enterprise-onboarding 2026-07-16 13:18:04 +08:00
修雨 7ca9ebeb57 Merge pull request #625 from PeterGuy326/codex/test-coverage-100-v2
fix: harden auth and reentrant CLI with 100% coverage
2026-07-16 13:13:27 +08:00
修雨 d202d58963 Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716 2026-07-16 12:40:55 +08:00
修雨 4068847742 Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2 2026-07-16 12:40:55 +08:00
修雨 536fd66029 Merge pull request #620 from PeterGuy326/codex/release-guardrails-v1
feat(release): add guarded beta and stable pipeline
2026-07-16 12:38:36 +08:00
修雨 a519a2ff8a fix(contact): validate enterprise onboarding writes 2026-07-16 12:38:05 +08:00
修雨 270771de0c test(contact): include onboarding cases in coverage gate 2026-07-16 12:27:25 +08:00
修雨 3ec8320680 feat(contact): add enterprise onboarding commands 2026-07-16 12:27:24 +08:00
修雨 2c4d539a02 Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2 2026-07-16 12:26:53 +08:00
修雨 59abfc7dfd Merge remote-tracking branch 'origin/main' into codex/pr620-fix 2026-07-16 12:24:23 +08:00
修雨 a676f3d606 Merge pull request #616 from typefield/agent/fix-calendar-rooms-help
fix: correct calendar rooms help metavar
2026-07-16 12:22:54 +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
修雨 f9b37486fd Merge remote-tracking branch 'origin/main' into codex/pr620-fix 2026-07-16 12:10:39 +08:00
修雨 ad6e22d8cf fix(coverage): keep keychain GCM seam in profiled file 2026-07-16 12:09:38 +08:00
修雨 05c0f1af1e Merge main@f56de38b into agent/fix-calendar-rooms-help 2026-07-16 12:08:28 +08:00
修雨 605d9360fc Merge pull request #636 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.53-beta.1
chore: update Homebrew beta formula for v1.0.53-beta.1
2026-07-16 12:05:23 +08:00
修雨 fb4e6a0fdb Merge origin/main into codex/fix-windows-portable-export-contract 2026-07-16 12:05:22 +08:00
修雨 2b715e35ad test(calendar): include help check in platform coverage 2026-07-16 12:04:31 +08:00
修雨 f56de38b79 Merge pull request #634 from typefield/feat/dws-devapp-get-by-appkey
feat(devapp): support get by app-key for app detail lookup
2026-07-16 12:01:17 +08:00
修雨 c2e5fec967 test(calendar): cover rooms help in helpers package 2026-07-16 11:59:31 +08:00
修雨 f0642c73d7 fix: preserve crypto errors and document behavior fixes 2026-07-16 11:56:44 +08:00
修雨 c1bbc183ad Merge pull request #560 from shangguanxuan633-lab/codex/pat-org-policy-denied-error
fix(pat): classify org policy denials
2026-07-16 11:56:09 +08:00
修雨 579cd86c6c Merge origin/main into agent/fix-calendar-rooms-help 2026-07-16 11:54:18 +08:00
玉澜andCursor b6c85f31c4 fix(devapp): cover get --app-key in platform coverage gate
Rename the get locator tests to TestCrossPlatformCoverage* so macOS/Windows changed-code coverage actually executes them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 11:40:55 +08:00
修雨 88d82f201f Merge origin/main into codex/fix-windows-portable-export-contract
# Conflicts:
#	CHANGELOG.md
2026-07-16 11:37:36 +08:00
玉澜andCursor b6df97fba1 fix(devapp): regenerate schema for get --app-key
Keep embedded catalog/bindings in sync with the new cobra flag so schema help-flag and policy checks pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 11:35:53 +08:00
玉澜andCursor a9ee1cd24d feat(devapp): support get by app-key for app detail lookup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 11:34:54 +08:00
修雨 2614d225f2 Merge remote-tracking branch 'origin/main' into codex/sync-pr636-main 2026-07-16 11:32:45 +08:00
修雨 e4faa0daa8 Merge remote-tracking branch 'origin/main' into codex/sync-pr560-main 2026-07-16 11:32:44 +08:00
修雨 975f378559 Merge pull request #632 from shangguanxuan633-lab/codex/jq18-schema-policy-portability
ci(schema): support jq 1.8 policy evaluation
2026-07-16 11:27:23 +08:00
修雨 28e83dfa57 Merge pull request #637 from DingTalk-Real-AI/codex/fix-devapp-interface-baseline
fix(ci): sync public interface baseline
2026-07-16 11:26:51 +08:00
修雨 7f07c22cd5 test: include coverage fixtures in native gates 2026-07-16 10:58:56 +08:00
修雨 089a661124 fix(ci): sync public interface baseline 2026-07-16 10:58:46 +08:00
shangguanxuan.sgx d2f7c667e2 Merge upstream/main into codex/pat-org-policy-denied-error 2026-07-16 10:56:15 +08:00
修雨 ff4961ebbe fix(release): harden mirror credential transport 2026-07-16 10:56:04 +08:00
修雨 68d3c76d2d Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2
# Conflicts:
#	internal/helpers/todo.go
2026-07-16 10:41:59 +08:00
修雨 3811a0d82e test(pat): include denial paths in platform coverage 2026-07-16 10:39:50 +08:00
修雨 e00019039c fix(auth): preserve force validation before support guard 2026-07-16 10:36:51 +08:00
DWS Release Bot 3fdf06fb51 chore: update beta formula for v1.0.53-beta.1 2026-07-16 10:31:05 +08:00
修雨 ede677e413 fix(ci): align interface coverage and baseline 2026-07-16 10:26:19 +08:00
修雨 b5abe6d328 fix(release): preserve latest main integration 2026-07-16 10:26:19 +08:00
修雨 51f531c3fd fix(release): isolate helper variables 2026-07-16 10:26:19 +08:00
修雨 8d21510aec fix(ci): enforce clean release workflows 2026-07-16 10:26:19 +08:00
修雨 1a51f3a8da fix(release): pin goreleaser to pushed tag 2026-07-16 10:26:19 +08:00
修雨 4d284c3740 fix(release): rebase guardrails onto sealed main 2026-07-16 10:26:18 +08:00
修雨 fe5f484c29 fix(ci): integrate code admission dependencies 2026-07-16 10:26:18 +08:00
修雨 8f4ab176a8 ci: add code admission gate (#53)
* ci: add code admission gate

* ci: fix fork release baseline

(cherry picked from commit 7ff2f3a5f0435209908822e141a6355fc6fa4aa6)
2026-07-16 10:26:18 +08:00
修雨 d288820b64 fix(release): preserve unreleased changelog entries (#55)
(cherry picked from commit e7989c1bb03ec461c638de89337d175f8ef115e4)
2026-07-16 10:26:18 +08:00
修雨 22d19863fb feat(release): add guarded prerelease and stable pipeline (#54)
* feat(release): add guarded prerelease and stable pipeline

* feat(release): add guided dws-release entry

(cherry picked from commit f7fa7b78f325f3574f0487862fc3e65bba5cdc96)
2026-07-16 10:26:18 +08:00
修雨 c02df07b64 Merge origin/main into codex/test-coverage-100-v2 2026-07-16 10:25:38 +08:00
修雨 e26e96eadc Merge remote-tracking branch 'origin/main' into codex/pr560-fix
# Conflicts:
#	CHANGELOG.md
2026-07-16 10:20:06 +08:00
修雨 5b746f610a fix(pat): short-circuit organization policy denials 2026-07-16 10:17:18 +08:00
修雨 74fa24ee1e fix(auth): reject unsupported Windows portable import 2026-07-16 10:13:41 +08:00
修雨 6a0cdbc323 fix: address coverage review follow-ups 2026-07-16 09:57:52 +08:00
修雨 397654890e fix(auth): isolate concurrent secure writes 2026-07-16 01:27:58 +08:00
修雨 a945663674 test: cover platform-specific coverage gaps 2026-07-16 00:49:16 +08:00
修雨 0201340b28 fix: close reentrant CLI file handles 2026-07-16 00:09:17 +08:00
修雨 e75df36dfc test: wait for event bus readiness 2026-07-15 23:54:44 +08:00
修雨 648d604757 test: preserve complete helper coverage after merge 2026-07-15 23:38:37 +08:00
修雨 d78de010ff Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2
# Conflicts:
#	internal/cli/stdin_test.go
#	internal/helpers/atomicwrite_test.go
#	internal/helpers/connect_agent_options_test.go
#	internal/helpers/connect_codex_appserver_test.go
#	internal/helpers/connect_daemon_test.go
#	internal/helpers/connect_lock.go
#	internal/helpers/doc.go
2026-07-15 23:14:16 +08:00
修雨 de35b08c8c test: make coverage fixtures portable on Windows 2026-07-15 23:02:20 +08:00
修雨 e0dc26c8c7 test: stabilize Windows native coverage 2026-07-15 22:19:49 +08:00
shangguanxuan.sgx adc87a92e4 ci(schema): support jq 1.8 policy evaluation 2026-07-15 18:55:19 +08:00
修雨 d23910ce35 test: isolate portable auth coverage on Windows 2026-07-15 17:50:28 +08:00
修雨 084188c7ac test: stabilize native coverage gates 2026-07-15 17:40:53 +08:00
修雨 1822b82232 test(logging): clarify terminal replacement coverage 2026-07-15 17:23:12 +08:00
修雨 44403e4d23 ci: retrigger pull request checks 2026-07-15 17:20:14 +08:00
修雨 ec29dc0e22 fix(logging): make file logger close terminal 2026-07-15 17:16:53 +08:00
修雨 9f0966c05a test: stabilize cross-platform coverage CI 2026-07-15 17:07:51 +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
修雨 3929719b51 Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2 2026-07-15 16:03:42 +08:00
修雨 d8ffea02ab test: close remaining coverage gaps 2026-07-15 15:19:25 +08:00
玉澜 87ac7048bd Merge remote-tracking branch 'upstream/main' into codex/pr-616-fix
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-15 12:10:41 +08:00
修雨 fafb6f47b9 test: reach complete unit coverage 2026-07-15 12:08:39 +08:00
修雨 8633246eff test: expand unit coverage 2026-07-15 11:41:30 +08:00
玉澜 5fde6222d4 fix: correct calendar rooms help metavar 2026-07-14 18:34:40 +08:00
shangguanxuan.sgx 00bb595768 fix(pat): classify org policy denials 2026-07-06 18:23:41 +08:00
475 changed files with 60228 additions and 3324 deletions
+6
View File
@@ -0,0 +1,6 @@
paths:
.github/workflows/release.yml:
ignore:
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
# bundled workflow schema has not caught up with the platform syntax.
- 'unexpected key "queue" for "concurrency" section'
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 100.0%"><title>coverage: 100.0%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4b0"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">100.0%</text><text x="865" y="150" fill-opacity=".3" textLength="430">100.0%</text></g><text x="865" y="140" textLength="430">100.0%</text></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+267 -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
@@ -38,10 +42,34 @@ jobs:
# version: v1.64.8
# args: ./...
test:
name: Test
actionlint:
name: Actionlint
runs-on: ubuntu-latest
timeout-minutes: 20
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: Check GitHub Actions workflows
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
test-race:
name: "Test (race: ${{ matrix.shard }})"
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard:
- app
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -55,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
@@ -103,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)
@@ -192,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
@@ -211,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 }}
@@ -231,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() {
@@ -258,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:
@@ -412,6 +647,7 @@ jobs:
name: CI Gate
needs:
- lint
- actionlint
- test
- test-darwin
- test-windows
@@ -431,6 +667,7 @@ jobs:
- name: Verify required checks
env:
LINT_RESULT: ${{ needs.lint.result }}
ACTIONLINT_RESULT: ${{ needs.actionlint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
@@ -446,6 +683,7 @@ jobs:
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Actionlint:$ACTIONLINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
+7 -17
View File
@@ -1,4 +1,5 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
@@ -10,8 +11,6 @@ on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
@@ -23,13 +22,14 @@ concurrency:
jobs:
mirror:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
- name: Checkout main history
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
@@ -41,14 +41,7 @@ jobs:
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
@@ -80,9 +73,6 @@ jobs:
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
# Release tags are immutable. Push only missing tags and fail closed
# on a conflicting existing ref instead of trying to move it.
timeout --signal=TERM 180s git push --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
-71
View File
@@ -1,71 +0,0 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
File diff suppressed because it is too large Load Diff
@@ -1,55 +0,0 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
# Each step has its own ceiling. Their 115-minute sum leaves five minutes
# for runner scheduling/teardown inside this 120-minute job deadline.
timeout-minutes: 120
steps:
- name: Check out repository
uses: actions/checkout@v4
timeout-minutes: 5
- name: Download GitHub release assets
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
timeout-minutes: 100
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+2 -7
View File
@@ -1,19 +1,14 @@
# GoReleaser configuration for dws
# Docs: https://goreleaser.com
#
# To release:
# git tag -a v0.1.0 -m "Release v0.1.0"
# git push origin v0.1.0
# To release, use scripts/release/release.sh. It seals main, validates the
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
#
# To test locally (no publish):
# goreleaser release --snapshot --clean
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: dws
+14 -2
View File
@@ -6,15 +6,28 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.53-beta.2] - 2026-07-16
This beta validates the accumulated post-v1.0.52 command surface, release automation, and runtime hardening changes, including enterprise contact onboarding, declarative shortcuts, Sheet/Aitable writes, multi-platform Homebrew formulas, and credential and target-validation fixes.
### Added
- **Contact enterprise onboarding commands** — adds `contact org create`, `contact user invite`, and `contact account create` for creating a DingTalk enterprise, inviting an employee by mobile, and provisioning an enterprise login account, with reviewed Schema contracts and mono/multi Skill routing.
- **Declarative shortcut commands** (#592) — adds 366 `dws <service> +<command>` shortcuts across 16 services, including one-to-one MCP wrappers and multi-step smart workflows. Shortcuts publish stable Agent-visible contracts with named flags, validation and confirmation metadata, dry-run protection for writes, catalog/help routing, and optional local YAML extensions and usage recording.
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
- **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.
### Changed
- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, Homebrew, and optional Gitee delivery with fail-closed recovery checks.
- **Reviewed historical release recovery proofs** — release preflight can recognize an explicitly pinned successful recovery delivery for a historical stable tag while still rejecting arbitrary workflow dispatches, mismatched commits, and incomplete release, signing, or publication jobs.
### Fixed
- **Sheet and Todo invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while Todo 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.
- **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
@@ -118,7 +131,6 @@ This release promotes the sealed **remove-discovery delivery** from the beta lin
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
## [1.0.47] - 2026-07-05
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
+1 -1
View File
@@ -48,7 +48,7 @@ git diff --check
1. Keep implementation and tests in sync.
2. Run `./scripts/dev/ci-local.sh`.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52-beta.5"
version "1.0.53-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-arm64.tar.gz"
sha256 "7164f2b0389ce0c3bc1d745b5c98082c1ef92c8547c9b123dcb4e83fe172f92e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-arm64.tar.gz"
sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-amd64.tar.gz"
sha256 "6ebd48fb96009cf2a81eb0af15216ba050620db55470d5c9937467aa66558879"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz"
sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-arm64.tar.gz"
sha256 "5f718244665c33a9327130874788d0fad36824ec29eb437ab82aa83e3d5a0579"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-arm64.tar.gz"
sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-amd64.tar.gz"
sha256 "e79abccc1e093b946be89282bd034ba60ab479cc8ee1a51001eb0d441c66125c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz"
sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-skills.zip"
sha256 "64c48271de89a94f9c184a475692e0e2f5e23bc0480c10824f717b21e3a83097"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip"
sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8"
end
def install
+39 -7
View File
@@ -1,9 +1,12 @@
GO ?= go
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -28,8 +31,11 @@ help:
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
@printf " make release - Build and publish a release via goreleaser\n"
@printf " make package - Build all release artifacts locally\n"
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
@@ -129,8 +135,8 @@ generate-schema-catalog:
-output internal/cli/schema_catalog.json
package:
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
publish-homebrew-formula:
@./scripts/release/publish-homebrew-formula.sh
@@ -138,6 +144,32 @@ publish-homebrew-formula:
setup-hooks:
@git config core.hooksPath scripts/hooks 2>/dev/null || true
changelog-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
changelog-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
release-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
release-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
release:
goreleaser release --clean
@./scripts/release/post-goreleaser.sh
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
@exit 2
+4 -1
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>
@@ -655,7 +658,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Contact | `contact` | Look up users, departments, labels, roster profiles and dismissals; create enterprises and enterprise accounts; invite employees |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
+1 -1
View File
@@ -647,7 +647,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职;创建企业、企业账号及邀请员工 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
+195
View File
@@ -0,0 +1,195 @@
// 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.
// interface-snapshot is an internal CI helper. It is intentionally a separate
// binary so it can be copied into a temporary worktree and compiled against an
// older revision's real Cobra root.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
switch args[0] {
case "generate":
if err := runGenerate(args[1:], stdout, stderr); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
return 0
case "compare":
compatible, err := runCompare(args[1:], stdout, stderr)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if !compatible {
return 1
}
return 0
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
return 2
}
}
func runGenerate(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "-", "snapshot output path, or - for stdout")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("generate accepts no positional arguments")
}
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
if err != nil {
return fmt.Errorf("create isolated home: %w", err)
}
defer os.RemoveAll(home)
environment := map[string]string{
"DWS_CONFIG_DIR": home,
"DWS_LANG": "en",
"HOME": home,
"NO_COLOR": "1",
"USERPROFILE": home,
}
type previousEnv struct {
value string
set bool
}
previous := make(map[string]previousEnv, len(environment))
for key, value := range environment {
oldValue, wasSet := os.LookupEnv(key)
previous[key] = previousEnv{value: oldValue, set: wasSet}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
}
defer func() {
for key, old := range previous {
if old.set {
_ = os.Setenv(key, old.value)
} else {
_ = os.Unsetenv(key)
}
}
}()
previousLang := i18n.Lang()
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
file, err := os.Create(filepath.Clean(*output))
if err != nil {
return fmt.Errorf("create snapshot %q: %w", *output, err)
}
writeErr := interfacesnapshot.Write(file, snapshot)
closeErr := file.Close()
if writeErr != nil {
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
}
if closeErr != nil {
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
}
return nil
}
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
flags.SetOutput(stderr)
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
if err := flags.Parse(args); err != nil {
return false, err
}
if flags.NArg() != 0 {
return false, fmt.Errorf("compare accepts no positional arguments")
}
if *currentPath == "" {
return false, fmt.Errorf("compare requires --current")
}
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
current, err := readSnapshot(*currentPath)
if err != nil {
return false, fmt.Errorf("read current snapshot: %w", err)
}
references := make(map[string]interfacesnapshot.Snapshot, 2)
if *basePath != "" {
references["main"], err = readSnapshot(*basePath)
if err != nil {
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
}
}
if *stablePath != "" {
references["stable"], err = readSnapshot(*stablePath)
if err != nil {
return false, fmt.Errorf("read stable snapshot: %w", err)
}
}
report := interfacesnapshot.CompareAll(current, references)
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
return false, fmt.Errorf("write comparison report: %w", err)
}
return report.Compatible, nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
defer file.Close()
return interfacesnapshot.Read(file)
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
}
+130
View File
@@ -0,0 +1,130 @@
// 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 main
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
if err != nil {
t.Fatalf("decode generated snapshot: %v", err)
}
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
for _, command := range snapshot.Commands {
commands[command.Path] = command
}
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
if _, ok := commands[path]; !ok {
t.Errorf("actual root snapshot is missing %q", path)
}
}
for _, path := range []string{"dws completion", "dws help"} {
if _, ok := commands[path]; ok {
t.Errorf("framework-noise path %q leaked into snapshot", path)
}
}
create := commands["dws dev app create"]
if !hasFlag(create.LocalFlags, "name", "string") {
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
}
if !hasFlag(create.InheritedFlags, "profile", "string") {
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
stable := commandSnapshot("dws", "dws legacy")
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", current)
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
stablePath := writeSnapshot(t, dir, "stable.json", stable)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", mergeBasePath,
"--stable", stablePath,
}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
}
}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
commands = append(commands, interfacesnapshot.Command{
Path: path,
Aliases: []string{},
LocalFlags: []interfacesnapshot.Flag{},
InheritedFlags: []interfacesnapshot.Flag{},
})
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: commands,
}
}
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
t.Helper()
path := filepath.Join(dir, name)
file, err := os.Create(path)
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if err := interfacesnapshot.Write(file, snapshot); err != nil {
file.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := file.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
return path
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
return true
}
}
return false
}
+3 -1
View File
@@ -19,6 +19,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
)
var exit = os.Exit
func main() {
os.Exit(app.Execute())
exit(app.Execute())
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"os"
"testing"
)
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
previousExit := exit
previousArgs := os.Args
t.Cleanup(func() {
exit = previousExit
os.Args = previousArgs
})
called := false
code := -1
exit = func(value int) {
called = true
code = value
}
os.Args = []string{"dws", "version"}
main()
if !called || code != 0 {
t.Fatalf("main exit = called %v, code %d", called, code)
}
}
+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
+5 -2
View File
@@ -164,16 +164,19 @@ _Group chats, conversations, messages, and robot/webhook integrations._
## `dws contact` — Contact Directory
_Users, departments, and directory lookups._
_Users, departments, directory lookups, and enterprise onboarding._
**6 commands**
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws contact account create` | Create a dedicated login account in the current enterprise. | When the user explicitly asks for an enterprise account or login account, rather than a new enterprise organization. |
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
| `dws contact org create` | Create a new DingTalk enterprise organization. | When the user explicitly asks to create or initialize an enterprise and provides its name and creator display name. |
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
| `dws contact user invite` | Invite one employee by mobile number into the current enterprise. | When the user explicitly asks to add an employee and has supplied the employee name and mobile number. |
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
+113
View File
@@ -0,0 +1,113 @@
# 发布手册(预发 / 正式)
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
## 日常只用一个入口
安装发布 Skill 后直接运行:
```bash
dws-release
```
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
```bash
dws-release config --remote origin
```
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
## 发布模型
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 只允许补正式 CHANGELOG,源码不得再变化
→ vX.Y.Z(正式发布)
```
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
## 预发发布
运行统一入口:
```bash
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
```bash
dws-release v1.2.3-beta.1 --publish
```
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
## 正式发布
beta 验证通过后,运行正式版入口:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
```
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
## CHANGELOG 契约
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
```markdown
## [1.2.3-beta.1] - 2026-07-11
### Changed
- 本次 beta 验证的用户可见变化。
```
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+3 -1
View File
@@ -49,6 +49,8 @@ var AllowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
var newHTTPRequest = http.NewRequestWithContext
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
type RawAPIRequest struct {
Method string // GET, POST, PUT, PATCH, DELETE
@@ -112,7 +114,7 @@ func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse,
bodyReader = bytes.NewReader(data)
}
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
httpReq, err := newHTTPRequest(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
+293
View File
@@ -0,0 +1,293 @@
package apiclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
type failingReader struct{ err error }
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestCrossPlatformCoverageDryRunAndParseCoverageEdges(t *testing.T) {
for _, tc := range []struct {
base string
path string
}{
{DefaultBaseURL, "/v1.0/test"},
{LegacyBaseURL, "/topapi/test"},
} {
var out bytes.Buffer
err := PrintDryRun(&out, RawAPIRequest{
Method: "post", Path: tc.path,
Params: map[string]any{"page": 1}, Data: map[string]any{"name": "value"},
}, tc.base, "token-value")
if err != nil || !strings.Contains(out.String(), "Dry Run") || !strings.Contains(out.String(), "toke****") {
t.Fatalf("PrintDryRun(%s) = %q, %v", tc.base, out.String(), err)
}
}
var out bytes.Buffer
if err := PrintDryRun(&out, RawAPIRequest{Method: "get", Path: "/x", Params: map[string]any{"bad": make(chan int)}, Data: make(chan int)}, DefaultBaseURL, "tiny"); err != nil {
t.Fatalf("PrintDryRun unsupported preview: %v", err)
}
wantErr := errors.New("read failed")
if _, err := ParseJSONMap("-", "--params", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("ParseJSONMap read error = %v", err)
}
if got, err := ParseJSONMap("-", "--params", strings.NewReader(" \n")); err != nil || got != nil {
t.Fatalf("ParseJSONMap empty stdin = %#v, %v", got, err)
}
if _, err := ParseOptionalBody("POST", "-", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("ParseOptionalBody read error = %v", err)
}
if got, err := ParseOptionalBody("POST", "-", strings.NewReader(" \n")); err != nil || got != nil {
t.Fatalf("ParseOptionalBody empty stdin = %#v, %v", got, err)
}
if _, err := ParseOptionalBody("POST", "{", strings.NewReader("")); err == nil {
t.Fatal("invalid optional body should fail")
}
}
func TestCrossPlatformCoverageResponseHandlingCoverageEdges(t *testing.T) {
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
textHeader := http.Header{"Content-Type": []string{"text/plain"}}
var out, errOut bytes.Buffer
opts := ResponseOptions{Format: output.FormatJSON, Out: &out, ErrOut: &errOut}
if err := HandleResponse(&RawAPIResponse{StatusCode: 500, Header: textHeader, Body: []byte(" failed ")}, opts); err == nil {
t.Fatal("plain HTTP error should fail")
}
for _, body := range [][]byte{nil, []byte("{")} {
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: body}, opts); err == nil {
t.Errorf("invalid JSON body %q should fail", body)
}
}
out.Reset()
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(`{"ok":true}`)}, opts); err != nil || !strings.Contains(out.String(), "ok") {
t.Fatalf("successful JSON response = %q, %v", out.String(), err)
}
for _, payload := range []string{
`{"errcode":1}`,
`{"message":"message failure"}`,
`{"error":"error failure"}`,
`{}`,
} {
status := 200
if !strings.Contains(payload, "errcode") {
status = 500
}
if err := HandleResponse(&RawAPIResponse{StatusCode: status, Header: jsonHeader, Body: []byte(payload)}, opts); err == nil {
t.Errorf("business/HTTP payload %s should fail", payload)
}
}
if err := checkDingTalkError([]any{1}, 200); err != nil || checkDingTalkError(map[string]any{"errcode": 0}, 200) != nil {
t.Fatal("successful DingTalk response classified as error")
}
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: textHeader, Body: []byte("binary")}, opts); err == nil {
t.Fatal("binary response without filename should fail")
}
invalidCD := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="unterminated`}}
if inferFilename(invalidCD) != "" {
t.Fatal("invalid content disposition should not infer filename")
}
if inferFilename(http.Header{}) != "" {
t.Fatal("missing content disposition should not infer filename")
}
dir := t.TempDir()
blockedParent := filepath.Join(dir, "file")
if err := os.WriteFile(blockedParent, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
opts.OutputPath = filepath.Join(blockedParent, "child.bin")
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
t.Fatal("binary mkdir failure should fail")
}
opts.OutputPath = dir
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
t.Fatal("binary write to directory should fail")
}
opts.OutputPath = ""
inferred := filepath.Join(dir, "inferred.bin")
header := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="` + inferred + `"`}}
if err := handleBinaryResponse(&RawAPIResponse{Header: header, Body: []byte("bytes")}, opts); err != nil {
t.Fatalf("inferred binary save: %v", err)
}
if !strings.Contains(errOut.String(), "已保存") {
t.Fatalf("binary status = %q", errOut.String())
}
for _, ct := range []string{" application/json; charset=utf-8 ", "text/json", "application/problem+json", "text/plain"} {
_ = isJSONContentType(ct)
}
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), json.Number("bad"), "5"} {
_ = toFloat64(value)
}
}
func TestCrossPlatformCoveragePaginationParsingAndInjectionEdges(t *testing.T) {
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
for _, resp := range []*RawAPIResponse{
{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: []byte("x")},
{StatusCode: 200, Header: jsonHeader},
{StatusCode: 200, Header: jsonHeader, Body: []byte("{")},
{StatusCode: 500, Header: jsonHeader, Body: []byte(`{"message":"bad"}`)},
} {
if _, _, _, err := parsePaginatedResponse(resp); err == nil {
t.Errorf("parsePaginatedResponse(%#v) should fail", resp)
}
}
responses := []struct {
body string
more bool
token string
}{
{`{"result":{"has_more":true,"next_cursor":12}}`, true, "12"},
{`{"has_more":true,"next_cursor":13}`, true, "13"},
{`{"next_token":"next"}`, true, "next"},
{`{"result":[],"has_more":false}`, false, ""},
}
for _, tc := range responses {
_, more, token, err := parsePaginatedResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(tc.body)})
if err != nil || more != tc.more || token != tc.token {
t.Errorf("pagination %s = %v, %q, %v", tc.body, more, token, err)
}
}
getCases := []RawAPIRequest{
{Method: "GET"},
{Method: "GET", Params: map[string]any{"cursor": "old"}},
{Method: "GET", Params: map[string]any{"next_token": "old"}},
{Method: "POST", Data: map[string]any{"cursor": "old"}},
{Method: "PUT", Data: map[string]any{}},
{Method: "POST", Data: "not-a-map"},
}
for _, req := range getCases {
_ = injectPageToken(req, "new")
}
logf(nil, "ignored")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func jsonHTTPResponse(body string) *http.Response {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
}
func TestCrossPlatformCoveragePaginationControlFlowEdges(t *testing.T) {
wantErr := errors.New("transport failed")
client := NewClient("token", DefaultBaseURL)
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
})
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); err == nil {
t.Fatal("first page parse error should fail")
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); !errors.Is(err, wantErr) {
t.Fatalf("first page transport error = %v", err)
}
calls := 0
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
}
return nil, wantErr
})
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1}); err == nil || len(pages) != 1 {
t.Fatalf("later transport error pages=%d err=%v", len(pages), err)
}
calls = 0
var logs bytes.Buffer
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
})
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "解析失败") {
t.Fatalf("later parse failure pages=%d logs=%q err=%v", len(pages), logs.String(), err)
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if pages, err := client.PaginateAll(ctx, RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 10}); !errors.Is(err, context.Canceled) || len(pages) != 1 {
t.Fatalf("pagination cancellation pages=%d err=%v", len(pages), err)
}
logs.Reset()
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageLimit: 1, PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "安全上限") {
t.Fatalf("pagination safety cap pages=%d logs=%q err=%v", len(pages), logs.String(), err)
}
}
func TestCrossPlatformCoverageClientAndValidationFailureEdges(t *testing.T) {
client := NewClient("token", DefaultBaseURL)
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://api.dingtalk.com/%zz"}); err == nil {
t.Fatal("Do with invalid URL should fail")
}
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://example.test/x"}); err == nil {
t.Fatal("Do to untrusted host should fail")
}
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "POST", Path: "/x", Data: make(chan int)}); err == nil {
t.Fatal("unmarshalable request body should fail")
}
if _, err := client.buildURL("https://api.dingtalk.com/%zz", nil); err == nil {
t.Fatal("invalid URL should fail")
}
oldNewRequest := newHTTPRequest
t.Cleanup(func() { newHTTPRequest = oldNewRequest })
wantCreateErr := errors.New("request creation failed")
newHTTPRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, wantCreateErr }
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantCreateErr) {
t.Fatalf("request creation error = %v", err)
}
newHTTPRequest = oldNewRequest
wantErr := errors.New("request failed")
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
t.Fatalf("HTTP transport error = %v", err)
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(failingReader{err: wantErr})}, nil
})
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
t.Fatalf("response read error = %v", err)
}
if ValidateTargetHost("http://%zz") == nil {
t.Fatal("invalid target URL should fail")
}
for _, r := range []rune{0x200B, 0xFEFF, 0x202A, 0x2028, 0x2066, 0x061C, 0xFDD0} {
if !isDangerousUnicode(r) || ValidateUserInput("x"+string(r), "field") == nil {
t.Errorf("dangerous rune %U was accepted", r)
}
}
if isDangerousUnicode('中') || ValidateUserInput("safe\t\n中文", "field") != nil {
t.Fatal("safe Unicode/input was rejected")
}
}
+20 -5
View File
@@ -26,13 +26,29 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type legacyTokenGetter interface {
GetToken() (string, string, error)
}
var (
newAccessTokenProvider = func(configDir string) accessTokenGetter {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
return provider
}
newLegacyTokenManager = func(configDir string) legacyTokenGetter {
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
return manager
}
)
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
// the same host compatibility hooks as MCP. It mirrors the former body of
// getCachedRuntimeToken (excluding process-level cache and timing).
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
provider := newAccessTokenProvider(configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
@@ -40,8 +56,7 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
return "", tokenErr
}
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
manager := newLegacyTokenManager(configDir)
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
return strings.TrimSpace(leg), nil
}
+17 -11
View File
@@ -38,6 +38,19 @@ type apiFlags struct {
baseURL string
}
type appTokenGetter interface {
GetToken(context.Context) (string, error)
}
var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter {
return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret}
}
var (
apiClientID = authpkg.ClientID
apiClientSecret = authpkg.ClientSecret
)
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
func newAPICommand(flags *GlobalFlags) *cobra.Command {
af := &apiFlags{}
@@ -271,10 +284,7 @@ func parseQueryStringToJSON(rawQuery string) string {
return "{}"
}
data, err := json.Marshal(paramsMap)
if err != nil {
return "{}"
}
data, _ := json.Marshal(paramsMap)
return string(data)
}
@@ -289,8 +299,8 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
}
// Resolve app credentials (clientID/clientSecret).
appKey := authpkg.ClientID()
appSecret := authpkg.ClientSecret()
appKey := apiClientID()
appSecret := apiClientSecret()
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
return "", apperrors.NewAuth(
@@ -308,11 +318,7 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
// Use AppTokenProvider for automatic caching and refresh.
configDir := defaultConfigDir()
provider := &authpkg.AppTokenProvider{
ConfigDir: configDir,
AppKey: appKey,
AppSecret: appSecret,
}
provider := newAppTokenProvider(configDir, appKey, appSecret)
token, err := provider.GetToken(ctx)
if err != nil {
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
+390
View File
@@ -0,0 +1,390 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type appFailWriter struct{ err error }
func (w appFailWriter) Write([]byte) (int, error) { return 0, w.err }
type fakeAccessTokenGetter struct {
token string
err error
}
func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
return g.token, g.err
}
type fakeLegacyTokenGetter struct {
token string
err error
}
func (g fakeLegacyTokenGetter) GetToken() (string, string, error) {
return g.token, "test", g.err
}
type fakeAppTokenGetter struct {
token string
err error
}
func (g fakeAppTokenGetter) GetToken(context.Context) (string, error) { return g.token, g.err }
type fakeSkillDirEntry struct{ dir bool }
func (e fakeSkillDirEntry) Name() string { return "entry" }
func (e fakeSkillDirEntry) IsDir() bool { return e.dir }
func (e fakeSkillDirEntry) Type() os.FileMode { return 0 }
func (e fakeSkillDirEntry) Info() (os.FileInfo, error) { return nil, nil }
func docPreflightServer(t *testing.T, result map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": result,
})
}))
}
func TestCrossPlatformCoverageDocDownloadPreflightCoverage(t *testing.T) {
runner := &runtimeRunner{}
base := executor.Invocation{CanonicalProduct: "doc", Tool: "download_file", Params: map[string]any{"nodeId": " node "}}
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{}); err != nil {
t.Fatal(err)
}
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{CanonicalProduct: "DOC", Tool: "download_file"}); err != nil {
t.Fatal(err)
}
if !isDocDownloadInvocation(base) || docDownloadNodeID(map[string]any{"node": " n "}) != "n" || docDownloadNodeID(map[string]any{"dentryUuid": " d "}) != "d" || docDownloadNodeID(map[string]any{"nodeId": 1}) != "" {
t.Fatal("doc download invocation helpers returned unexpected values")
}
if documentInfoExtension(map[string]any{"data": map[string]any{"extension": " doc "}}) != "doc" ||
documentInfoExtension(map[string]any{"extension": " pdf "}) != "pdf" ||
stringAtPath(map[string]any{"x": "value"}, "x", "nested") != "" ||
stringAtPath(map[string]any{"x": 1}, "x") != "" {
t.Fatal("document extension helpers returned unexpected values")
}
if unsupportedAXLSDownloadError() == nil {
t.Fatal("missing AXLS validation error")
}
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookErr := errors.New("classified")
for _, tc := range []struct {
name string
result map[string]any
hooks *edition.Hooks
want string
}{
{name: "ok", result: map[string]any{"content": map[string]any{"result": map[string]any{"extension": "docx"}}}, hooks: &edition.Hooks{}},
{name: "edition classifier", result: map[string]any{"content": map[string]any{}}, hooks: &edition.Hooks{ClassifyToolResult: func(map[string]any) error { return hookErr }}, want: "classified"},
{name: "pat", result: map[string]any{"content": map[string]any{"errorCode": "PAT_NO_PERMISSION"}}, hooks: &edition.Hooks{}, want: "PAT_NO_PERMISSION"},
{name: "mcp error", result: map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "mcp failed"}}}, hooks: &edition.Hooks{}, want: "mcp failed"},
{name: "business error", result: map[string]any{"content": map[string]any{"success": false, "errorMsg": "business failed"}}, hooks: &edition.Hooks{}, want: "business failed"},
{name: "axls", result: map[string]any{"content": map[string]any{"data": map[string]any{"extension": "AXLS"}}}, hooks: &edition.Hooks{}, want: "extension=axls"},
} {
t.Run(tc.name, func(t *testing.T) {
edition.Override(tc.hooks)
server := docPreflightServer(t, tc.result)
defer server.Close()
client := transport.NewClient(nil)
client.TrustedDomains = []string{"127.0.0.1"}
err := runner.preflightDocDownload(context.Background(), client, server.URL, base)
if tc.want == "" && err != nil {
t.Fatalf("preflight error = %v", err)
}
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatalf("preflight error = %v, want %q", err, tc.want)
}
})
}
server := docPreflightServer(t, map[string]any{})
endpoint := server.URL
server.Close()
client := transport.NewClient(nil)
client.TrustedDomains = []string{"127.0.0.1"}
client.MaxRetries = 0
if err := runner.preflightDocDownload(context.Background(), client, endpoint, base); err == nil {
t.Fatal("network preflight failure succeeded")
}
}
func TestCrossPlatformCoverageRootHelpRemainingCoverage(t *testing.T) {
configureRootHelp(nil)
renderRootGlobalFlags(nil)
if visiblePersistentFlags(nil) != nil || formatRootFlag(nil) != "" || commandShort(nil) != "" || visibleMCPRootCommands(nil) != nil || visibleUtilityRootCommands(nil) != nil {
t.Fatal("nil root helper contract changed")
}
oldEdition := edition.Get()
edition.Override(&edition.Hooks{VisibleProducts: func() []string { return []string{"service"} }})
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
root := &cobra.Command{Use: "root", Long: "long help"}
root.SetOut(io.Discard)
root.PersistentFlags().StringP("value", "x", "", "value")
root.PersistentFlags().Bool("hidden", false, "hidden")
_ = root.PersistentFlags().MarkHidden("hidden")
root.AddCommand(&cobra.Command{Use: "service", Short: "service"}, &cobra.Command{Use: "utility", Short: "utility"})
configureRootHelp(root)
if err := root.Commands()[0].Help(); err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"help", "missing"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"help", "utility"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if got := formatRootFlag(root.PersistentFlags().Lookup("value")); !strings.Contains(got, "-x") {
t.Fatalf("formatted flag = %q", got)
}
if got := formatRootFlag(root.PersistentFlags().Lookup("hidden")); !strings.Contains(got, "--hidden") {
t.Fatalf("formatted long flag = %q", got)
}
_ = commandShort(&cobra.Command{Use: "help", Short: "Help about any command"})
renderRootGlobalFlags(&cobra.Command{Use: "no-flags"})
edition.Override(&edition.Hooks{})
SetDynamicServers(nil)
_ = visibleMCPRootCommands(root)
}
func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
oldHome, oldExe, oldEval := userHomeDir, executablePath, evaluateSymlink
oldEdition := edition.Get()
t.Cleanup(func() {
userHomeDir, executablePath, evaluateSymlink = oldHome, oldExe, oldEval
edition.Override(oldEdition)
})
t.Setenv("DWS_CONFIG_DIR", "")
edition.Override(&edition.Hooks{})
userHomeDir = func() (string, error) { return "", errors.New("home") }
executablePath = func() (string, error) { return "", errors.New("exe") }
if got := defaultConfigDir(); got != ".dws" {
t.Fatalf("fallback config dir = %q", got)
}
executablePath = func() (string, error) { return filepath.Join("", "tmp", "dws"), nil }
evaluateSymlink = func(string) (string, error) { return "", errors.New("link") }
if got := exeRelativeConfigDir(); !strings.HasSuffix(got, filepath.Join("tmp", ".dws")) {
t.Fatalf("executable config dir = %q", got)
}
userHomeDir = func() (string, error) { return "/home/test", nil }
if got := defaultConfigDir(); got != filepath.Join("/home/test", ".dws") {
t.Fatalf("home config dir = %q", got)
}
edition.Override(&edition.Hooks{ConfigDir: func() string { return "/edition" }})
if got := defaultConfigDir(); got != "/edition" {
t.Fatalf("edition config dir = %q", got)
}
oldProvider, oldManager := newAccessTokenProvider, newLegacyTokenManager
t.Cleanup(func() { newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldManager })
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
t.Fatalf("decryption error = %v", err)
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
t.Fatalf("legacy token = %q, %v", got, err)
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} }
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} }
edition.Override(&edition.Hooks{})
other := filepath.Join(t.TempDir(), "other")
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
t.Fatal("auxiliary provider failure succeeded")
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); !errors.Is(err, authpkg.ErrTokenDecryption) {
t.Fatalf("auxiliary decryption error = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", other)
ResetRuntimeTokenCache()
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
t.Fatal("current config without credentials succeeded")
}
edition.Override(&edition.Hooks{IsEmbedded: true})
if !strings.Contains(noCredentialsError().Error(), "认证") {
t.Fatal("embedded credentials error changed")
}
}
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
oldStop := stopStdio
t.Cleanup(func() {
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
stopStdio = oldStop
stdioMu.Lock()
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
})
fail := errors.New("failure")
_ = oldFactory(t.TempDir())
markAccessTokenStale = func(string) error { return fail }
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
t.Fatalf("mark stale error = %v", err)
}
markAccessTokenStale = func(string) error { return nil }
for _, tc := range []struct {
getter fakeAccessTokenGetter
want string
}{
{getter: fakeAccessTokenGetter{err: fail}, want: "failure"},
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
{getter: fakeAccessTokenGetter{token: " refreshed "}},
} {
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
got, err := ForceRefreshAccessToken(context.Background(), "config")
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatalf("refresh error = %v, want %q", err, tc.want)
}
if tc.want == "" && (err != nil || got != "refreshed") {
t.Fatalf("refreshed token = %q, %v", got, err)
}
}
stopStdio = func(*transport.StdioClient) error { return fail }
RegisterStdioClient("all", transport.NewStdioClient("unused", nil, nil))
StopAllStdioClients()
RegisterStdioClient("one", transport.NewStdioClient("unused", nil, nil))
if !StopStdioClient("one") {
t.Fatal("registered stdio client not stopped")
}
RegisterStdioClient("plugin/server", transport.NewStdioClient("unused", nil, nil))
if got := StopStdioClientsByPlugin("plugin"); got != 1 {
t.Fatalf("stopped plugin clients = %d", got)
}
}
func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *testing.T) {
root := t.TempDir()
writeOverlay := filepath.Join(root, "overlay.json")
if err := os.WriteFile(writeOverlay, []byte(`{"toolOverrides":{"tool":{}}}`), 0o600); err != nil {
t.Fatal(err)
}
for _, raw := range []json.RawMessage{
json.RawMessage(`"missing.json"`),
json.RawMessage(`"unterminated`),
json.RawMessage(`{`),
json.RawMessage(`"overlay.json"`),
json.RawMessage(`{"id":"","command":"","toolOverrides":{"tool":{}}}`),
} {
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: raw}}}}
overlay := resolveStdioOverlay(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)})
if overlay.ID == "" || overlay.Command == "" {
t.Fatalf("overlay defaults missing: %#v", overlay)
}
}
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: json.RawMessage(`{}`)}}}}
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server"}); descriptor.Endpoint == "" {
t.Fatalf("empty overlay descriptor = %#v", descriptor)
}
p.Manifest.MCPServers["server"].CLI = json.RawMessage(`{"toolOverrides":{"tool":{}}}`)
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)}); descriptor.Endpoint == "" {
t.Fatalf("stdio overlay registration = %#v", descriptor)
}
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
}
edition.Override(&edition.Hooks{MergeHeaders: func(map[string]string) map[string]string { return map[string]string{"claw-type": "custom"} }})
if got := effectiveClawType(); got != "custom" {
t.Fatalf("custom claw type = %q", got)
}
}
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
}
t.Setenv("DWS_CONFIG_DIR", "configured")
configCmd := &cobra.Command{Use: "config"}
var configOut bytes.Buffer
configCmd.SetOut(&configOut)
if err := writeConfigJSON(configCmd, filterVisible(nil), true); err != nil {
t.Fatal(err)
}
list := newConfigListCommand()
list.SetOut(io.Discard)
_ = list.Flags().Set("category", "core")
_ = list.Flags().Set("show-values", "true")
_ = list.Flags().Set("show-hidden", "true")
_ = list.Flags().Set("json", "true")
if err := runConfigList(list, nil); err != nil {
t.Fatal(err)
}
cacheRoot := &cobra.Command{Use: "root"}
cacheRoot.PersistentFlags().String("format", "", "")
cacheCmd := &cobra.Command{Use: "cache"}
cacheRoot.AddCommand(cacheCmd)
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+282
View File
@@ -0,0 +1,282 @@
package app
import (
"context"
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageAPIAndTimingRemainingCoverage(t *testing.T) {
oldProvider := newAppTokenProvider
oldClientID, oldClientSecret := apiClientID, apiClientSecret
oldMarshal, oldMkdir := timingMarshalIndent, timingMkdirAll
oldWrite, oldRemove, oldRename := timingWriteFile, timingRemove, timingRename
oldRead, oldHome := timingReadFile, timingUserHomeDir
t.Cleanup(func() {
newAppTokenProvider = oldProvider
apiClientID, apiClientSecret = oldClientID, oldClientSecret
timingMarshalIndent, timingMkdirAll = oldMarshal, oldMkdir
timingWriteFile, timingRemove, timingRename = oldWrite, oldRemove, oldRename
timingReadFile, timingUserHomeDir = oldRead, oldHome
authpkg.SetClientID("")
authpkg.SetClientSecret("")
})
fail := errors.New("failure")
apiClientID = func() string { return "" }
apiClientSecret = func() string { return "" }
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
t.Fatal("missing raw API credentials succeeded")
}
apiClientID = func() string { return "<placeholder>" }
apiClientSecret = func() string { return "secret" }
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
t.Fatal("placeholder raw API credentials succeeded")
}
apiClientID, apiClientSecret = authpkg.ClientID, authpkg.ClientSecret
authpkg.SetClientID("app-key")
authpkg.SetClientSecret("app-secret")
for _, tc := range []struct {
getter fakeAppTokenGetter
want string
}{
{getter: fakeAppTokenGetter{err: fail}, want: "failure"},
{getter: fakeAppTokenGetter{token: " "}, want: "为空"},
{getter: fakeAppTokenGetter{token: " token "}},
} {
newAppTokenProvider = func(string, string, string) appTokenGetter { return tc.getter }
got, err := resolveRawAPIToken(context.Background(), "")
if tc.want != "" && (err == nil || !containsText(err.Error(), tc.want)) {
t.Fatalf("raw token error = %v, want %q", err, tc.want)
}
if tc.want == "" && (err != nil || got != "token") {
t.Fatalf("raw token = %q, %v", got, err)
}
}
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
endpoint := server.URL
server.Close()
apiclient.AllowedHosts["127.0.0.1"] = true
t.Cleanup(func() { delete(apiclient.AllowedHosts, "127.0.0.1") })
cmd := &cobra.Command{Use: "api"}
cmd.SetContext(context.Background())
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := runAPI(cmd, []string{"GET", "/path"}, &GlobalFlags{Token: "token", Timeout: 1}, &apiFlags{baseURL: endpoint}); err == nil {
t.Fatal("closed raw API endpoint succeeded")
}
collector := NewTimingCollector()
collector.Print(io.Discard)
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
timingMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
collector.WriteReportIfEnabled("v", "cmd")
timingMarshalIndent = oldMarshal
timingUserHomeDir = func() (string, error) { return "", fail }
t.Setenv(PerfReportEnv, "auto")
collector.WriteReportIfEnabled("v", "cmd")
if defaultPerfReportPath() != "" {
t.Fatal("home-dir failure produced a report path")
}
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
timingMkdirAll = func(string, os.FileMode) error { return fail }
collector.WriteReportIfEnabled("v", "cmd")
timingMkdirAll = oldMkdir
timingWriteFile = func(string, []byte, os.FileMode) error { return fail }
removed := false
timingRemove = func(string) error { removed = true; return nil }
collector.WriteReportIfEnabled("v", "cmd")
if !removed {
t.Fatal("failed temporary report was not removed")
}
timingWriteFile = oldWrite
timingRemove = oldRemove
renamed := false
timingRename = func(string, string) error { renamed = true; return fail }
collector.WriteReportIfEnabled("v", "cmd")
if !renamed {
t.Fatal("report rename was not attempted")
}
timingReadFile = func(string) ([]byte, error) { return []byte("{"), nil }
if _, err := LoadLatestReport(); err == nil {
t.Fatal("malformed performance report succeeded")
}
}
func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() {
edition.Override(oldEdition)
SetDynamicServers(nil)
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
for _, raw := range []string{
"not a url",
"https://mcp.dingtalk.com/path?q=1#fragment",
"https://pre-mcp.example.test:8443/path/",
"https://mcp.example.test/path/",
} {
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if got := defaultPATGatewayBaseURL(); got == "" {
t.Fatalf("gateway for %q is blank", raw)
}
}
endpoints := map[string]string{}
products := map[string]bool{}
aliases := map[string]string{}
tools := map[string]string{}
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
registerDynamicServer(mcptypes.ServerDescriptor{
Endpoint: "https://server.test",
CLI: mcptypes.CLIOverlay{
ID: "id", Command: "command", Aliases: []string{"alias", " "},
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
},
}, endpoints, products, aliases, tools)
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
}
SetDynamicServers(nil)
dynamicMu.Lock()
dynamicEndpoints = map[string]string{}
dynamicProducts = map[string]bool{}
dynamicAliases = map[string]string{}
dynamicToolEndpoints = map[string]string{}
dynamicMu.Unlock()
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
t.Fatal("cold-start PAT fallback did not resolve")
}
SetDynamicServers(nil)
if _, ok := directRuntimeEndpoint(" ", " "); ok {
t.Fatal("blank runtime endpoint resolved")
}
t.Setenv("DINGTALK_CUSTOM_MCP_URL", "https://override.test")
if got, ok := directRuntimeEndpoint("custom", ""); !ok || got != "https://override.test" {
t.Fatalf("environment runtime endpoint = %q, %v", got, ok)
}
if got, ok := directRuntimeEndpoint(devappProductID, ""); !ok || got == "" {
t.Fatal("devapp fallback did not resolve")
}
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
t.Fatal("PAT fallback did not resolve")
}
edition.Override(&edition.Hooks{
StaticServers: func() []edition.ServerInfo { return []edition.ServerInfo{{ID: "other", Endpoint: ""}} },
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{{ID: "other", Endpoint: "https://other.test", Prefixes: []string{" ", "wanted"}}}
},
})
if got, ok := directRuntimeEndpoint("wanted", ""); !ok || got != "https://other.test" {
t.Fatalf("edition runtime endpoint = %q, %v", got, ok)
}
dynamicMu.Lock()
dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints = nil, nil, nil, nil
dynamicMu.Unlock()
AppendDynamicServer(mcptypes.ServerDescriptor{
Endpoint: "https://append.test",
CLI: mcptypes.CLIOverlay{
ID: "append", Command: "append-command", Aliases: []string{"append-alias"},
Tools: []mcptypes.CLITool{{Name: "append-tool"}},
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"append-override": {}, "skip": {ServerOverride: "other"}, " ": {},
},
},
})
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
t.Fatalf("append override endpoint = %q, %v", got, ok)
}
}
func containsText(value, substring string) bool {
for i := 0; i+len(substring) <= len(value); i++ {
if value[i:i+len(substring)] == substring {
return true
}
}
return false
}
func TestCrossPlatformCoverageEmbeddedSkillAndTinyCommandsRemainingCoverage(t *testing.T) {
oldStat, oldTemp, oldRemove := embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll
oldWalk, oldRead := embeddedSkillWalkDir, embeddedSkillReadFile
oldMkdir, oldWrite := embeddedSkillMkdirAll, embeddedSkillWriteFile
t.Cleanup(func() {
embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll = oldStat, oldTemp, oldRemove
embeddedSkillWalkDir, embeddedSkillReadFile = oldWalk, oldRead
embeddedSkillMkdirAll, embeddedSkillWriteFile = oldMkdir, oldWrite
})
fail := errors.New("failure")
embeddedSkillStat = func(string) (os.FileInfo, error) { return nil, nil }
embeddedSkillMkdirTemp = func(string, string) (string, error) { return "", fail }
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
t.Fatalf("embedded mkdir error = %v", err)
}
embeddedSkillMkdirTemp = func(string, string) (string, error) { return t.TempDir(), nil }
removed := false
embeddedSkillRemoveAll = func(string) error { removed = true; return nil }
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
return fn("entry", nil, fail)
}
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) || !removed {
t.Fatalf("embedded walk error = %v, removed=%v", err, removed)
}
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
}
embeddedSkillReadFile = func(string) ([]byte, error) { return nil, fail }
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
t.Fatalf("embedded read error = %v", err)
}
embeddedSkillReadFile = func(string) ([]byte, error) { return []byte("skill"), nil }
embeddedSkillMkdirAll = func(string, os.FileMode) error { return fail }
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
t.Fatalf("embedded nested mkdir error = %v", err)
}
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
return fn("skills/codex/dir", fakeSkillDirEntry{dir: true}, nil)
}
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
t.Fatalf("embedded directory mkdir error = %v", err)
}
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
}
embeddedSkillMkdirAll = func(string, os.FileMode) error { return nil }
embeddedSkillWriteFile = func(string, []byte, os.FileMode) error { return fail }
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
t.Fatalf("embedded write error = %v", err)
}
merged := mergeTopLevelCommands([]*cobra.Command{nil, {}})
if len(merged) != 0 {
t.Fatalf("empty legacy commands = %#v", merged)
}
root := &cobra.Command{Use: "root"}
completion := newCompletionCommand(root)
if err := completion.RunE(completion, []string{"other"}); err != nil {
t.Fatal(err)
}
catalog := newCatalogCommand(nil)
catalog.SetOut(io.Discard)
if err := catalog.RunE(catalog, nil); err != nil {
t.Fatal(err)
}
}
+14 -6
View File
@@ -16,6 +16,14 @@ import (
"github.com/spf13/cobra"
)
var (
auditCSVWrite = func(writer *csv.Writer, record []string) error { return writer.Write(record) }
auditCSVFlush = func(writer *csv.Writer) { writer.Flush() }
auditCSVError = func(writer *csv.Writer) error { return writer.Error() }
auditExit = os.Exit
auditVerify = audit.VerifyFile
)
func newAuditCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "audit",
@@ -109,7 +117,7 @@ func newAuditVerifyCommand() *cobra.Command {
}
}
valid, brokenAt, err := audit.VerifyFile(target)
valid, brokenAt, err := auditVerify(target)
if err != nil {
return fmt.Errorf("校验失败: %w", err)
}
@@ -117,7 +125,7 @@ func newAuditVerifyCommand() *cobra.Command {
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
} else {
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
os.Exit(1)
auditExit(1)
}
return nil
},
@@ -179,7 +187,7 @@ func exportCSV(files []string) error {
w := csv.NewWriter(os.Stdout)
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
if err := w.Write(header); err != nil {
if err := auditCSVWrite(w, header); err != nil {
return fmt.Errorf("写入 CSV 表头失败: %w", err)
}
@@ -213,7 +221,7 @@ func exportCSV(files []string) error {
strconv.FormatInt(evt.DurationMs, 10),
evt.ErrCategory,
}
if err := w.Write(row); err != nil {
if err := auditCSVWrite(w, row); err != nil {
f.Close()
return fmt.Errorf("写入 CSV 记录失败: %w", err)
}
@@ -225,8 +233,8 @@ func exportCSV(files []string) error {
f.Close()
}
w.Flush()
if err := w.Error(); err != nil {
auditCSVFlush(w)
if err := auditCSVError(w); err != nil {
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
}
return nil
+264
View File
@@ -0,0 +1,264 @@
package app
import (
"encoding/csv"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
)
type auditCoverageSink struct {
events []*audit.Event
emitErr error
closeErr error
}
func (sink *auditCoverageSink) Emit(event *audit.Event) error {
sink.events = append(sink.events, event)
return sink.emitErr
}
func (sink *auditCoverageSink) Close() error { return sink.closeErr }
func TestCrossPlatformCoverageAuditCommandsAndFileHelpersCoverage(t *testing.T) {
originalExit, originalVerify := auditExit, auditVerify
t.Cleanup(func() { auditExit, auditVerify = originalExit, originalVerify })
dir := t.TempDir()
t.Setenv(audit.EnvAuditDir, dir)
if auditDir() != dir {
t.Fatalf("auditDir() = %q", auditDir())
}
tail := newAuditTailCommand()
tail.SetArgs([]string{"--lines", "1"})
if err := tail.Execute(); err == nil || !strings.Contains(err.Error(), "无审计记录") {
t.Fatalf("audit tail(empty) error = %v", err)
}
path := filepath.Join(dir, "audit-20260101.jsonl")
if err := os.WriteFile(path, []byte("one\ntwo\n"), 0o600); err != nil {
t.Fatal(err)
}
tail = newAuditTailCommand()
tail.SetArgs([]string{"--lines", "1"})
if err := tail.Execute(); err != nil {
t.Fatal(err)
}
if _, err := tailFile(filepath.Join(dir, "missing"), 1); err == nil {
t.Fatal("tailFile(missing) error = nil")
}
tailErrorDir := t.TempDir()
if err := os.Mkdir(filepath.Join(tailErrorDir, "audit-20260101.jsonl"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv(audit.EnvAuditDir, tailErrorDir)
tail = newAuditTailCommand()
if err := tail.Execute(); err == nil {
t.Fatal("audit tail(directory record) error = nil")
}
oversize := filepath.Join(dir, "oversize")
if err := os.WriteFile(oversize, []byte(strings.Repeat("x", 2*1024*1024)), 0o600); err != nil {
t.Fatal(err)
}
if _, err := tailFile(oversize, 1); err == nil {
t.Fatal("tailFile(oversize) error = nil")
}
exportDir := t.TempDir()
t.Setenv(audit.EnvAuditDir, exportDir)
export := newAuditExportCommand()
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
t.Fatalf("audit export(empty) error = %v", err)
}
if err := os.WriteFile(filepath.Join(exportDir, "audit-20260102.jsonl"), []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
for _, format := range []string{"jsonl", "csv"} {
export = newAuditExportCommand()
export.SetArgs([]string{"--since", "2026-01-01", "--until", "2026-01-03", "--format", format})
if err := export.Execute(); err != nil {
t.Fatalf("audit export(%s) error = %v", format, err)
}
}
export = newAuditExportCommand()
export.SetArgs([]string{"--format", "xml"})
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "不支持的格式") {
t.Fatalf("audit export(xml) error = %v", err)
}
t.Setenv(audit.EnvAuditDir, filepath.Join(exportDir, "missing"))
export = newAuditExportCommand()
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "查找审计文件失败") {
t.Fatalf("audit export(missing dir) error = %v", err)
}
if err := exportJSONL([]string{filepath.Join(dir, "missing")}); err == nil {
t.Fatal("exportJSONL(missing) error = nil")
}
if err := exportJSONL([]string{oversize}); err == nil {
t.Fatal("exportJSONL(oversize) error = nil")
}
if err := exportCSV([]string{filepath.Join(dir, "missing")}); err == nil {
t.Fatal("exportCSV(missing) error = nil")
}
blank := filepath.Join(dir, "blank")
if err := os.WriteFile(blank, []byte("\n \n{}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := exportCSV([]string{blank}); err != nil {
t.Fatal(err)
}
if err := exportCSV([]string{oversize}); err == nil {
t.Fatal("exportCSV(oversize) error = nil")
}
originalWrite, originalFlush, originalError := auditCSVWrite, auditCSVFlush, auditCSVError
t.Cleanup(func() { auditCSVWrite, auditCSVFlush, auditCSVError = originalWrite, originalFlush, originalError })
auditCSVWrite = func(*csv.Writer, []string) error { return errors.New("write") }
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "表头") {
t.Fatalf("exportCSV(header error) = %v", err)
}
calls := 0
auditCSVWrite = func(writer *csv.Writer, row []string) error {
calls++
if calls > 1 {
return errors.New("row")
}
return originalWrite(writer, row)
}
if err := exportCSV([]string{blank}); err == nil || !strings.Contains(err.Error(), "记录") {
t.Fatalf("exportCSV(row error) = %v", err)
}
auditCSVWrite = originalWrite
auditCSVError = func(*csv.Writer) error { return errors.New("flush") }
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "刷新") {
t.Fatalf("exportCSV(flush error) = %v", err)
}
t.Setenv(audit.EnvAuditDir, t.TempDir())
verify := newAuditVerifyCommand()
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
t.Fatalf("audit verify(empty) error = %v", err)
}
verify = newAuditVerifyCommand()
verify.SetArgs([]string{"--file", filepath.Join(dir, "missing")})
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "校验失败") {
t.Fatalf("audit verify(missing) error = %v", err)
}
validDir := t.TempDir()
writer, err := audit.NewDateRotatingWriter(validDir, 1)
if err != nil {
t.Fatal(err)
}
sink := audit.NewFileSink(writer, audit.NewChain(validDir), nil)
if err := sink.Emit(&audit.Event{Timestamp: time.Now(), Product: "test", Command: "ok"}); err != nil {
t.Fatal(err)
}
if err := sink.Close(); err != nil {
t.Fatal(err)
}
validFile, err := audit.LatestAuditFile(validDir)
if err != nil {
t.Fatal(err)
}
verify = newAuditVerifyCommand()
verify.SetArgs([]string{"--file", validFile})
if err := verify.Execute(); err != nil {
t.Fatal(err)
}
broken := filepath.Join(t.TempDir(), "audit-broken.jsonl")
if err := os.WriteFile(broken, []byte(`{"prev_hash":"wrong","hash":"wrong"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
exitCode := 0
auditExit = func(code int) { exitCode = code }
auditVerify = func(string) (bool, int, error) { return false, 1, nil }
verify = newAuditVerifyCommand()
verify.SetArgs([]string{"--file", broken})
if err := verify.Execute(); err != nil || exitCode != 1 {
t.Fatalf("audit verify(broken) = %v, exit=%d", err, exitCode)
}
t.Setenv(audit.EnvAuditDir, "")
if auditDir() == "" {
t.Fatal("default auditDir() is empty")
}
}
func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
previousSink, previousLoader := sharedAuditSink, loadTokenForProfile
t.Cleanup(func() {
sharedAuditSink = previousSink
loadTokenForProfile = previousLoader
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
resetAuditIdentityCache()
})
sharedAuditSink = nil
auditCloseOnce = sync.Once{}
CloseAuditSink()
failedClose := &auditCoverageSink{closeErr: errors.New("close")}
sharedAuditSink = failedClose
auditCloseOnce = sync.Once{}
CloseAuditSink()
bad := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(bad, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(audit.EnvAudit, "1")
t.Setenv(audit.EnvAuditDir, filepath.Join(bad, "child"))
auditSinkOnce = sync.Once{}
sharedAuditSink = nil
if _, ok := setupAuditSink().(audit.NopSink); !ok {
t.Fatalf("setupAuditSink(error) = %T", sharedAuditSink)
}
t.Setenv(audit.EnvAuditDebug, "1")
fileLogger = logging.Setup(t.TempDir())
t.Cleanup(func() {
if fileLogger != nil {
fileLogger.Close()
fileLogger = nil
}
})
auditReport("coverage %d", 1)
loadTokenForProfile = func(string, string) (*auth.TokenData, error) { return nil, errors.New("identity") }
resetAuditIdentityCache()
if actor, _ := auditIdentity(); actor.UserID != "" {
t.Fatalf("auditIdentity(error) = %+v", actor)
}
invocation := executor.Invocation{CanonicalProduct: "calendar", Tool: "list", Params: map[string]any{"token": "secret"}}
emitAudit(nil, "nil", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
emitAudit(audit.NopSink{}, "nop", time.Now(), invocation, "", nil, "test")
recording := &auditCoverageSink{}
emitAudit(recording, "ok", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
if len(recording.events) != 1 || recording.events[0].Result != "success" {
t.Fatalf("successful audit events = %#v", recording.events)
}
typed := &apperrors.Error{Category: apperrors.CategoryAuth, Reason: "expired"}
emitAudit(recording, "typed", time.Now(), invocation, "", typed, "test")
if recording.events[1].ErrReason != "expired" {
t.Fatalf("typed audit event = %#v", recording.events[1])
}
recording.emitErr = errors.New("emit")
emitAudit(recording, "failed", time.Now(), invocation, "", errors.New("plain"), "test")
if category, reason := classifyAuditError(nil); category != "" || reason != "" {
t.Fatalf("classifyAuditError(nil) = %q, %q", category, reason)
}
if category, reason := classifyAuditError(fmt.Errorf("wrapped: %w", typed)); category != string(apperrors.CategoryAuth) || reason != "expired" {
t.Fatalf("classifyAuditError(typed) = %q, %q", category, reason)
}
if category, reason := classifyAuditError(errors.New("plain")); category != "unknown" || reason != "plain" {
t.Fatalf("classifyAuditError(plain) = %q, %q", category, reason)
}
}
+119 -69
View File
@@ -136,7 +136,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
AccessToken: cfg.Token,
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
}
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
if err := authSaveTokenData(configDir, tokenData); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
}
case cfg.Device:
@@ -146,7 +146,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
tokenData, err = provider.Login(loginCtx)
tokenData, err = authDeviceLogin(provider, loginCtx)
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
}
@@ -159,7 +159,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
}
@@ -182,7 +182,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
var initialPlan *pat.LoginRecommendPlan
if postLoginTUIMode {
var planErr error
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
initialPlan, planErr = authPlanLoginRecommend(cmd.Context(), patCaller)
if planErr != nil {
return planErr
}
@@ -207,11 +207,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
retryFormat = "table"
}
run := func(ctx context.Context) error {
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
return authRunLoginRecommend(ctx, patCaller, cmd.ErrOrStderr(), opts)
}
err := run(cmd.Context())
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
return runDirectPATAuthCheckWaitOnly(
return authRunDirectPATWait(
cmd.Context(),
&GlobalFlags{Format: retryFormat},
patErr,
@@ -234,11 +234,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
return err
}
fmt.Fprintln(w)
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
} else {
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
}
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
if tokenData != nil {
if tokenData.CorpName != "" {
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
@@ -279,12 +275,50 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
var (
authLoginGuideActionSelector = selectAuthLoginGuideAction
authLoginGuideActionApplier = applyAuthLoginGuideAction
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
loginRecommendProductSelector = selectLoginRecommendProducts
authLoginInteractiveTerminal = isInteractiveTerminal
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
authLoginGuideActionSelector = selectAuthLoginGuideAction
authLoginGuideActionApplier = applyAuthLoginGuideAction
authLoginManualCredentialsPrompt = promptAuthLoginManualCredentials
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
loginRecommendProductSelector = selectLoginRecommendProducts
authLoginInteractiveTerminal = isInteractiveTerminal
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") }
authRunForm = (*huh.Form).Run
authSaveTokenData = authpkg.SaveTokenData
authSaveAppConfig = authpkg.SaveAppConfig
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
return provider.Login(ctx)
}
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, force bool) (*authpkg.TokenData, error) {
return provider.Login(ctx, force)
}
authOAuthStatus = func(provider *authpkg.OAuthProvider) (*authpkg.TokenData, error) { return provider.Status() }
authOAuthAccessToken = func(provider *authpkg.OAuthProvider, ctx context.Context) (string, error) {
return provider.GetAccessToken(ctx)
}
authOAuthExchange = func(provider *authpkg.OAuthProvider, ctx context.Context, code, uid string) (*authpkg.TokenData, error) {
return provider.ExchangeAuthCode(ctx, code, uid)
}
authPlanLoginRecommend = pat.PlanLoginRecommendAuthorization
authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions
authRunDirectPATWait = runDirectPATAuthCheckWaitOnly
authResolveProfile = authpkg.ResolveProfile
authRevokeToken = authpkg.RevokeTokenRemote
authDeleteProfileToken = authpkg.DeleteTokenDataForProfile
authEnsureProfilesMigration = authpkg.EnsureProfilesMigration
authLoadProfiles = authpkg.LoadProfiles
authDeleteAllTokenData = authpkg.DeleteAllTokenData
authDeleteTokenData = authpkg.DeleteTokenData
authMarkProfileStatus = authpkg.MarkProfileStatus
authPortableExportSupported = authpkg.PortableExportSupported
authPortableSourceReady = authpkg.PortableAuthSourceReady
authPortableTargetPopulated = authpkg.PortableAuthTargetPopulated
authExportPortableBundle = authpkg.ExportPortableAuthBundle
authImportPortableBundle = authpkg.ImportPortableAuthBundle
authAtomicWrite = helpers.AtomicWrite
authReadFile = os.ReadFile
authRemove = os.Remove
authDeleteAppConfig = authpkg.DeleteAppConfig
)
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
@@ -301,7 +335,7 @@ func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
Value(&choice),
),
).WithTheme(authLoginHuhTheme())
if err := form.Run(); err != nil {
if err := authRunForm(form); err != nil {
return "", fmt.Errorf("使用引导选择中止: %w", err)
}
return choice, nil
@@ -315,13 +349,13 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
return nil
case authLoginGuideManualCredentials:
clientID, clientSecret, err := promptAuthLoginManualCredentials()
clientID, clientSecret, err := authLoginManualCredentialsPrompt()
if err != nil {
return err
}
authpkg.SetClientID(clientID)
authpkg.SetClientSecret(clientSecret)
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
if err := authSaveAppConfig(configDir, &authpkg.AppConfig{
ClientID: clientID,
ClientSecret: authpkg.PlainSecret(clientSecret),
}); err != nil {
@@ -335,33 +369,34 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
func promptAuthLoginManualCredentials() (string, string, error) {
var clientID, clientSecret string
nonEmpty := func(label string) func(string) error {
return func(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s 不能为空", label)
}
return nil
}
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("输入 AppKey").
Value(&clientID).
Validate(nonEmpty("AppKey")),
Validate(authLoginNonEmpty("AppKey")),
huh.NewInput().
Title("输入 AppSecret").
EchoMode(huh.EchoModePassword).
Value(&clientSecret).
Validate(nonEmpty("AppSecret")),
Validate(authLoginNonEmpty("AppSecret")),
),
).WithTheme(authLoginHuhTheme())
if err := form.Run(); err != nil {
if err := authRunForm(form); err != nil {
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
}
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
}
func authLoginNonEmpty(label string) func(string) error {
return func(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s 不能为空", label)
}
return nil
}
}
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
choice := pat.LoginRecommendScopeRecommended
form := huh.NewForm(
@@ -376,7 +411,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
Value(&choice),
),
).WithTheme(authLoginHuhTheme())
if err := form.Run(); err != nil {
if err := authRunForm(form); err != nil {
return "", fmt.Errorf("授权范围选择中止: %w", err)
}
return choice, nil
@@ -451,21 +486,21 @@ func newAuthStatusCommand() *cobra.Command {
var statusErr error
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := provider.Status(); err == nil {
if data, err := authOAuthStatus(provider); err == nil {
tokenData = data
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
_, refreshErr := provider.GetAccessToken(refreshCtx)
_, refreshErr := authOAuthAccessToken(provider, refreshCtx)
cancel()
if refreshErr == nil {
if updatedData, statusErr := provider.Status(); statusErr == nil {
if updatedData, statusErr := authOAuthStatus(provider); statusErr == nil {
tokenData = updatedData
refreshed = true
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
_ = authDeleteTokenData(configDir)
} else if tokenData != nil {
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
_ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
}
}
if authStatusAuthenticated(tokenData) {
@@ -536,7 +571,7 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := cmd.Flags().GetString("to")
target, err := authMigrateTarget(cmd)
if err != nil {
return apperrors.NewInternal("failed to read --to")
}
@@ -583,36 +618,36 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
if _, err := authResolveProfile(configDir, selector); err != nil {
return apperrors.NewValidation(err.Error())
}
restoreProfile := pushRuntimeProfile(selector)
defer restoreProfile()
_ = authpkg.RevokeTokenRemote(ctx)
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
_ = authRevokeToken(ctx)
if err := authDeleteProfileToken(configDir, selector); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
if err := authEnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
cfg, err := authLoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
_ = authpkg.RevokeTokenRemote(ctx)
_ = authRevokeToken(ctx)
} else {
for _, profile := range cfg.Profiles {
restoreProfile := pushRuntimeProfile(profile.CorpID)
_ = authpkg.RevokeTokenRemote(ctx)
_ = authRevokeToken(ctx)
restoreProfile()
}
}
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
if err := authDeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
@@ -631,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: "导出可迁移认证包",
@@ -655,18 +694,21 @@ func newAuthExportCommand() *cobra.Command {
if !asBase64 && output == "" {
return apperrors.NewValidation("--output is required unless --base64 is used")
}
if !authpkg.PortableExportSupported() {
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 后重新登录",
keychain.DisableKeychainEnv,
))
}
if !authpkg.PortableAuthSourceReady() {
if !authPortableSourceReady() {
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
}
var bundle bytes.Buffer
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
if err := authExportPortableBundle(defaultConfigDir(), &bundle); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
}
@@ -676,7 +718,7 @@ func newAuthExportCommand() *cobra.Command {
_, err := cmd.OutOrStdout().Write(payload)
return err
}
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
if err := authAtomicWrite(output, payload, config.FilePerm); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
@@ -684,7 +726,7 @@ func newAuthExportCommand() *cobra.Command {
return nil
}
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
if err := authAtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
@@ -698,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: "导入可迁移认证包",
@@ -726,13 +772,15 @@ 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) {
if !force && authPortableTargetPopulated(configDir) {
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
}
payload, err := os.ReadFile(input)
payload, err := authReadFile(input)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
}
@@ -742,7 +790,7 @@ func newAuthImportCommand() *cobra.Command {
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
}
}
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
report, err := authImportPortableBundle(configDir, bytes.NewReader(payload))
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
}
@@ -787,7 +835,7 @@ func newAuthExchangeCommand() *cobra.Command {
configureOAuthProviderCompatibility(provider, configDir)
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
defer cancel()
tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid))
tokenData, err := authOAuthExchange(provider, exchangeCtx, code, strings.TrimSpace(uid))
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
}
@@ -826,12 +874,12 @@ func newAuthResetCommand() *cobra.Command {
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
if err := authDeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
}
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteAppConfig(configDir)
_ = authRemove(filepath.Join(configDir, "mcp_url"))
_ = authRemove(filepath.Join(configDir, "token"))
_ = authDeleteAppConfig(configDir)
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
@@ -907,20 +955,22 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin
Options(options...).
Height(height).
Value(&selected).
Validate(func(values []string) error {
if len(values) == 0 {
return fmt.Errorf("至少选择一个授权业务域")
}
return nil
}),
Validate(authLoginProductsNonEmpty),
),
).WithTheme(authLoginHuhTheme())
if err := form.Run(); err != nil {
if err := authRunForm(form); err != nil {
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
}
return selected, nil
}
func authLoginProductsNonEmpty(values []string) error {
if len(values) == 0 {
return fmt.Errorf("至少选择一个授权业务域")
}
return nil
}
func authLoginHuhTheme() *huh.Theme {
t := huh.ThemeBase()
@@ -1123,7 +1173,7 @@ func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
if selector == "" {
return "", nil
}
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
return strings.TrimSpace(profile.CorpID), nil
}
if strings.HasPrefix(selector, "ding") {
@@ -1182,7 +1232,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
return nil
}
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
if err := authSaveTokenData(configDir, &updated); err != nil {
return err
}
*data = updated
@@ -0,0 +1,788 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
type authCoverageCaller struct {
result *edition.ToolResult
err error
}
func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result, c.err
}
func (*authCoverageCaller) Format() string { return "json" }
func (*authCoverageCaller) DryRun() bool { return false }
func (*authCoverageCaller) Fields() string { return "" }
func (*authCoverageCaller) JQ() string { return "" }
func authCoverageRoot(child *cobra.Command, format string, yes bool) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
root := &cobra.Command{Use: "dws"}
root.SetContext(context.Background())
child.SetContext(context.Background())
root.PersistentFlags().String("format", format, "")
root.PersistentFlags().Bool("yes", yes, "")
root.PersistentFlags().String("profile", "", "")
root.AddCommand(child)
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
root.SetOut(out)
root.SetErr(errOut)
return root, out, errOut
}
func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string, yes bool, flags map[string]string) (string, string, error) {
t.Helper()
cmd := newAuthLoginCommand(caller)
root, out, errOut := authCoverageRoot(cmd, format, yes)
for name, value := range flags {
flagSet := cmd.Flags()
if root.PersistentFlags().Lookup(name) != nil {
flagSet = root.PersistentFlags()
}
if err := flagSet.Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
err := cmd.RunE(cmd, nil)
return out.String(), errOut.String(), err
}
func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
oldEdition := edition.Get()
oldRunForm := authRunForm
oldPrompt := authLoginManualCredentialsPrompt
oldSaveConfig := authSaveAppConfig
oldResolve := authResolveProfile
t.Cleanup(func() {
edition.Override(oldEdition)
authRunForm = oldRunForm
authLoginManualCredentialsPrompt = oldPrompt
authSaveAppConfig = oldSaveConfig
authResolveProfile = oldResolve
})
edition.Override(&edition.Hooks{})
parent := buildAuthCommand(nil)
if parent.CommandPath() == "" || parent.RunE(parent, nil) != nil {
t.Fatal("auth parent should render help")
}
edition.Override(&edition.Hooks{HideAuthLogin: true})
if got := buildAuthCommand(nil).Commands(); len(got) != 7 {
t.Fatalf("hidden-login subcommands = %d, want 7", len(got))
}
authRunForm = func(*huh.Form) error { return nil }
if choice, err := selectAuthLoginGuideAction(); err != nil || choice != authLoginGuideDirectCLI {
t.Fatalf("guide choice = %q, %v", choice, err)
}
if id, secret, err := promptAuthLoginManualCredentials(); err != nil || id != "" || secret != "" {
t.Fatalf("manual prompt = %q/%q, %v", id, secret, err)
}
if mode, err := selectLoginRecommendScopeMode(); err != nil || mode != pat.LoginRecommendScopeRecommended {
t.Fatalf("scope mode = %q, %v", mode, err)
}
products := []pat.LoginRecommendProduct{{ProductCode: "doc"}, {ProductCode: ""}}
if selected, err := selectLoginRecommendProducts(products); err != nil || len(selected) != 1 || selected[0] != "doc" {
t.Fatalf("selected products = %#v, %v", selected, err)
}
if selected, err := selectLoginRecommendProducts(nil); err != nil || selected != nil {
t.Fatalf("empty selected products = %#v, %v", selected, err)
}
if selected, err := selectLoginRecommendProducts([]pat.LoginRecommendProduct{{}}); err != nil || selected != nil {
t.Fatalf("blank selected products = %#v, %v", selected, err)
}
many := make([]pat.LoginRecommendProduct, 16)
for i := range many {
many[i].ProductCode = fmt.Sprintf("p%d", i)
}
if selected, err := selectLoginRecommendProducts(many); err != nil || len(selected) != 16 {
t.Fatalf("many selected products = %d, %v", len(selected), err)
}
if authLoginProductsNonEmpty(nil) == nil || authLoginProductsNonEmpty([]string{"doc"}) != nil {
t.Fatal("product validator mismatch")
}
authRunForm = func(*huh.Form) error { return errors.New("cancel") }
if _, err := selectAuthLoginGuideAction(); err == nil {
t.Fatal("guide cancellation should fail")
}
if _, _, err := promptAuthLoginManualCredentials(); err == nil {
t.Fatal("credential cancellation should fail")
}
if _, err := selectLoginRecommendScopeMode(); err == nil {
t.Fatal("scope cancellation should fail")
}
if _, err := selectLoginRecommendProducts(products); err == nil {
t.Fatal("product cancellation should fail")
}
if authLoginNonEmpty("field")(" ") == nil || authLoginNonEmpty("field")("value") != nil {
t.Fatal("non-empty validator mismatch")
}
cmd := &cobra.Command{}
cmd.SetErr(io.Discard)
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideDirectCLI); err != nil {
t.Fatal(err)
}
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideConfigureAgentApp); err != nil {
t.Fatal(err)
}
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), "unknown"); err == nil {
t.Fatal("unknown guide action should fail")
}
authLoginManualCredentialsPrompt = func() (string, string, error) { return "", "", errors.New("cancel") }
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
t.Fatal("manual prompt error should propagate")
}
authLoginManualCredentialsPrompt = func() (string, string, error) { return "id", "secret", nil }
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return errors.New("save") }
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
t.Fatal("app-config save error should propagate")
}
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err != nil {
t.Fatal(err)
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
return &authpkg.Profile{CorpID: " ding-profile "}, nil
}
if got, err := resolveAuthLoginTargetCorpID("cfg", "name"); err != nil || got != "ding-profile" {
t.Fatalf("resolved target = %q, %v", got, err)
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
for selector, want := range map[string]struct {
value string
err bool
}{"": {"", false}, "ding-direct": {"ding-direct", false}, "other": {"", true}} {
got, err := resolveAuthLoginTargetCorpID("cfg", selector)
if got != want.value || (err != nil) != want.err {
t.Fatalf("target %q = %q, %v", selector, got, err)
}
}
badToken := &cobra.Command{}
badToken.Flags().Bool("token", false, "")
if _, err := resolveAuthLoginConfig(badToken); err == nil {
t.Fatal("invalid token flag should fail")
}
badDevice := &cobra.Command{}
badDevice.Flags().String("token", "", "")
badDevice.Flags().String("device", "", "")
if _, err := resolveAuthLoginConfig(badDevice); err == nil {
t.Fatal("invalid device flag should fail")
}
badForce := &cobra.Command{}
badForce.Flags().String("token", "", "")
badForce.Flags().Bool("device", false, "")
badForce.Flags().String("force", "", "")
if _, err := resolveAuthLoginConfig(badForce); err == nil {
t.Fatal("invalid force flag should fail")
}
badRecommend := &cobra.Command{}
badRecommend.Flags().String("token", "", "")
badRecommend.Flags().Bool("device", false, "")
badRecommend.Flags().Bool("force", false, "")
badRecommend.Flags().String("recommend", "", "")
if _, err := resolveAuthLoginConfig(badRecommend); err == nil {
t.Fatal("invalid recommend flag should fail")
}
}
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldSave := authSaveTokenData
oldDevice := authDeviceLogin
oldOAuth := authOAuthLogin
oldRunRecommend := authRunLoginRecommend
oldRunWait := authRunDirectPATWait
oldPlan := authPlanLoginRecommend
oldScope := loginRecommendScopeModeSelector
oldProducts := loginRecommendProductSelector
oldInteractive := authLoginInteractiveTerminal
oldResolve := authResolveProfile
t.Cleanup(func() {
authSaveTokenData = oldSave
authDeviceLogin = oldDevice
authOAuthLogin = oldOAuth
authRunLoginRecommend = oldRunRecommend
authRunDirectPATWait = oldRunWait
authPlanLoginRecommend = oldPlan
loginRecommendScopeModeSelector = oldScope
loginRecommendProductSelector = oldProducts
authLoginInteractiveTerminal = oldInteractive
authResolveProfile = oldResolve
})
authInteractiveFalse := func() bool { return false }
authLoginInteractiveTerminal = authInteractiveFalse
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"profile": "bad"}); err == nil {
t.Fatal("invalid login profile should fail")
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token"}); err == nil {
t.Fatal("token save should fail")
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
if out, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": " token "}); err != nil || !strings.Contains(out, "登录成功") {
t.Fatalf("token login = %q, %v", out, err)
}
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
t.Fatalf("json token login = %q, %v", out, err)
}
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
return nil, errors.New("device")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
t.Fatal("device error should propagate")
}
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
t.Fatal(err)
}
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return nil, errors.New("oauth")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
t.Fatal("oauth error should propagate")
}
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
}, nil
}
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{}`}}}}
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
t.Fatalf("oauth success = %q, %v", out, err)
}
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
return errors.New("recommend")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
t.Fatal("recommend error should propagate")
}
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
t.Fatal("JSON recommend error should propagate")
}
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
return &apperrors.PATError{RawJSON: `{"code":"PAT_SCOPE_AUTH_REQUIRED"}`}
}
waited := false
authRunDirectPATWait = func(context.Context, *GlobalFlags, *apperrors.PATError, io.Writer) error {
waited = true
return nil
}
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err != nil || !waited {
t.Fatalf("PAT wait = %v, waited=%v", err, waited)
}
authLoginInteractiveTerminal = func() bool { return true }
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
return nil, errors.New("plan")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
t.Fatal("plan error should propagate")
}
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
return &pat.LoginRecommendPlan{AllGranted: true}, nil
}
if _, stderr, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !strings.Contains(stderr, "全部授权") {
t.Fatalf("all-granted plan = %q, %v", stderr, err)
}
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
return &pat.LoginRecommendPlan{Scopes: []string{"scope"}, Products: []pat.LoginRecommendProduct{{ProductCode: "doc"}}}, nil
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return "", errors.New("scope") }
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
t.Fatal("scope selector error should propagate")
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return pat.LoginRecommendScopeAll, nil }
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { return []string{"doc"}, nil }
selected := false
authRunLoginRecommend = func(_ context.Context, _ edition.ToolCaller, _ io.Writer, opts pat.LoginRecommendOptions) error {
if opts.ProductSelector != nil {
_, err := opts.ProductSelector(opts.InitialPlan.Products)
selected = err == nil
}
return nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !selected {
t.Fatalf("interactive recommendation = %v, selected=%v", err, selected)
}
}
func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
oldSave := authSaveTokenData
t.Cleanup(func() { authSaveTokenData = oldSave })
ctx := context.Background()
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", nil, nil); err != nil {
t.Fatal(err)
}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, &authpkg.TokenData{}); err != nil {
t.Fatal(err)
}
complete := &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u", UserName: "User"}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
t.Fatal(err)
}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("caller error should propagate")
}
for _, text := range []string{"", "not-json", `{"result":[]}`, `{"result":[{"orgEmployeeModel":{}}]}`} {
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: text}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding"}); err != nil {
t.Fatalf("invalid contact %q: %v", text, err)
}
}
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("corp mismatch should fail")
}
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
t.Fatal(err)
}
unchangedPartial := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", unchangedPartial, &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u"}); err != nil {
t.Fatal(err)
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("enrichment save should fail")
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
data := &authpkg.TokenData{CorpID: "ding"}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
t.Fatalf("enriched = %#v, %v", data, err)
}
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
t.Fatal("nil result should not parse")
}
if got := firstNonEmptyString(" ", " value ", "later"); got != "value" {
t.Fatalf("first non-empty = %q", got)
}
}
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
configDir := t.TempDir()
device := authpkg.NewDeviceFlowProvider(configDir, nil)
_, _ = authDeviceLogin(device, ctx)
oauth := authpkg.NewOAuthProvider(configDir, nil)
_, _ = authOAuthStatus(oauth)
_, _ = authOAuthAccessToken(oauth, ctx)
_, _ = authOAuthLogin(oauth, ctx, true)
_, _ = authOAuthExchange(oauth, ctx, "code", "uid")
_ = fmt.Sprintf("%v", os.ErrNotExist)
}
func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldStatus := authOAuthStatus
oldAccess := authOAuthAccessToken
oldDelete := authDeleteTokenData
oldMark := authMarkProfileStatus
oldResolve := authResolveProfile
oldRevoke := authRevokeToken
oldDeleteProfile := authDeleteProfileToken
oldMigrate := authEnsureProfilesMigration
oldLoadProfiles := authLoadProfiles
oldDeleteAll := authDeleteAllTokenData
t.Cleanup(func() {
edition.Override(oldEdition)
authOAuthStatus = oldStatus
authOAuthAccessToken = oldAccess
authDeleteTokenData = oldDelete
authMarkProfileStatus = oldMark
authResolveProfile = oldResolve
authRevokeToken = oldRevoke
authDeleteProfileToken = oldDeleteProfile
authEnsureProfilesMigration = oldMigrate
authLoadProfiles = oldLoadProfiles
authDeleteAllTokenData = oldDeleteAll
})
badStatus := newAuthStatusCommand()
bad := &cobra.Command{}
bad.Flags().Bool("profile", false, "")
if err := badStatus.RunE(bad, nil); err == nil {
t.Fatal("invalid profile flag should fail")
}
runStatus := func(format string) (string, error) {
cmd := newAuthStatusCommand()
_, out, _ := authCoverageRoot(cmd, format, false)
err := cmd.RunE(cmd, nil)
return out.String(), err
}
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
edition.Override(&edition.Hooks{})
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "auth login") {
t.Fatalf("plain unauthenticated status = %q, %v", out, err)
}
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
return nil, keychain.NewUnavailableError("read", errors.New("status"))
}
edition.Override(&edition.Hooks{})
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "未登录") {
t.Fatalf("status error = %q, %v", out, err)
}
if out, err := runStatus("json"); err != nil || !strings.Contains(out, `"authenticated": false`) {
t.Fatalf("json status error = %q, %v", out, err)
}
now := time.Now()
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour), CorpID: "ding", CorpName: "Corp"}
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "已登录") || !strings.Contains(out, "缺失或已过期") {
t.Fatalf("valid access status = %q, %v", out, err)
}
valid.RefreshToken = "r"
valid.RefreshExpAt = now.Add(time.Hour)
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "Refresh Token:") {
t.Fatalf("valid refresh status = %q, %v", out, err)
}
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
updated := &authpkg.TokenData{AccessToken: "new", ExpiresAt: now.Add(time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
calls := 0
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
calls++
if calls == 1 {
return expired, nil
}
return updated, nil
}
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "new", nil }
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "自动刷新") {
t.Fatalf("refreshed status = %q, %v", out, err)
}
calls = 0
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
calls++
if calls == 1 {
return expired, nil
}
return nil, errors.New("second status")
}
if _, err := runStatus("table"); err != nil {
t.Fatal(err)
}
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
deleted := false
marked := false
authDeleteTokenData = func(string) error { deleted = true; return errors.New("ignored") }
authMarkProfileStatus = func(string, string, string) error { marked = true; return errors.New("ignored") }
edition.Override(&edition.Hooks{AutoPurgeToken: true})
if _, err := runStatus("table"); err != nil || !deleted {
t.Fatalf("auto-purge = %v, deleted=%v", err, deleted)
}
edition.Override(&edition.Hooks{})
if _, err := runStatus("table"); err != nil || !marked {
t.Fatalf("mark-expired = %v, marked=%v", err, marked)
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
t.Fatal("missing profile should fail")
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "ding"}, nil }
authRevokeToken = func(context.Context) error { return errors.New("ignored") }
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
t.Fatal("profile delete should fail")
}
authDeleteProfileToken = func(string, string) error { return nil }
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil {
t.Fatal(err)
}
authEnsureProfilesMigration = func(string) error { return errors.New("migrate") }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
t.Fatal("migration should fail")
}
authEnsureProfilesMigration = func(string) error { return nil }
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, errors.New("load") }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
t.Fatal("load profiles should fail")
}
revokes := 0
authRevokeToken = func(context.Context) error { revokes++; return nil }
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
authDeleteAllTokenData = func(string) error { return nil }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 1 {
t.Fatalf("empty profiles = %v, revokes=%d", err, revokes)
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "a"}, {CorpID: "b"}}}, nil
}
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 3 {
t.Fatalf("profile revokes = %v, revokes=%d", err, revokes)
}
authDeleteAllTokenData = func(string) error { return errors.New("delete all") }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
t.Fatal("delete-all should fail")
}
logoutFailure := newAuthLogoutCommand()
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
t.Fatal("logout-all command failure should propagate")
}
logoutFailure = newAuthLogoutCommand()
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
_ = logoutFailure.Flags().Set("profile", "ding")
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
t.Fatal("logout-one command failure should propagate")
}
logout := newAuthLogoutCommand()
badLogout := &cobra.Command{}
badLogout.Flags().Bool("profile", false, "")
badLogout.SetContext(context.Background())
if err := logout.RunE(badLogout, nil); err == nil {
t.Fatal("invalid logout profile flag should fail")
}
authDeleteAllTokenData = func(string) error { return nil }
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return &authpkg.ProfilesConfig{}, nil }
_, out, _ := authCoverageRoot(logout, "table", false)
if err := logout.RunE(logout, nil); err != nil || !strings.Contains(out.String(), "重新登录") {
t.Fatalf("logout = %q, %v", out.String(), err)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
authDeleteProfileToken = func(string, string) error { return nil }
logout = newAuthLogoutCommand()
_, out, _ = authCoverageRoot(logout, "table", false)
if err := logout.Flags().Set("profile", "ding"); err != nil {
t.Fatal(err)
}
if err := logout.RunE(logout, nil); err != nil || strings.Contains(out.String(), "重新登录") {
t.Fatalf("embedded logout = %q, %v", out.String(), err)
}
}
func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldSupported := authPortableExportSupported
oldReady := authPortableSourceReady
oldTarget := authPortableTargetPopulated
oldExport := authExportPortableBundle
oldImport := authImportPortableBundle
oldAtomic := authAtomicWrite
oldRead := authReadFile
oldExchange := authOAuthExchange
oldDeleteAll := authDeleteAllTokenData
oldRemove := authRemove
oldDeleteConfig := authDeleteAppConfig
t.Cleanup(func() {
edition.Override(oldEdition)
authPortableExportSupported = oldSupported
authPortableSourceReady = oldReady
authPortableTargetPopulated = oldTarget
authExportPortableBundle = oldExport
authImportPortableBundle = oldImport
authAtomicWrite = oldAtomic
authReadFile = oldRead
authOAuthExchange = oldExchange
authDeleteAllTokenData = oldDeleteAll
authRemove = oldRemove
authDeleteAppConfig = oldDeleteConfig
})
export := newAuthExportCommandWithSupport(func() error { return nil })
badString := &cobra.Command{}
badString.Flags().Bool("output", false, "")
if err := export.RunE(badString, nil); err == nil {
t.Fatal("invalid output flag should fail")
}
badBool := &cobra.Command{}
badBool.Flags().String("output", "x", "")
badBool.Flags().String("base64", "", "")
if err := export.RunE(badBool, nil); err == nil {
t.Fatal("invalid base64 flag should fail")
}
_, _, _ = authCoverageRoot(export, "table", false)
if err := export.RunE(export, nil); err == nil {
t.Fatal("missing export output should fail")
}
authPortableExportSupported = func() bool { return false }
_ = export.Flags().Set("output", "out")
if err := export.RunE(export, nil); err == nil {
t.Fatal("unsupported export should fail")
}
authPortableExportSupported = func() bool { return true }
authPortableSourceReady = func() bool { return false }
if err := export.RunE(export, nil); err == nil {
t.Fatal("unready export should fail")
}
authPortableSourceReady = func() bool { return true }
authExportPortableBundle = func(string, io.Writer) error { return errors.New("export") }
if err := export.RunE(export, nil); err == nil {
t.Fatal("export failure should propagate")
}
authExportPortableBundle = func(_ string, w io.Writer) error { _, _ = io.WriteString(w, "bundle"); return nil }
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := export.RunE(export, nil); err == nil {
t.Fatal("raw write should fail")
}
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
if err := export.RunE(export, nil); err != nil {
t.Fatal(err)
}
export = newAuthExportCommandWithSupport(func() error { return nil })
_, out, _ := authCoverageRoot(export, "table", false)
_ = export.Flags().Set("base64", "true")
export.SetOut(&appFailWriter{err: errors.New("stdout")})
if err := export.RunE(export, nil); err == nil {
t.Fatal("stdout failure should propagate")
}
export.SetOut(out)
_ = export.Flags().Set("output", "encoded")
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := export.RunE(export, nil); err == nil {
t.Fatal("base64 write should fail")
}
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
if err := export.RunE(export, nil); err != nil {
t.Fatal(err)
}
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
badInput := &cobra.Command{}
badInput.Flags().Bool("input", false, "")
if err := importCmd.RunE(badInput, nil); err == nil {
t.Fatal("invalid input flag should fail")
}
badImportBase64 := &cobra.Command{}
badImportBase64.Flags().String("input", "x", "")
badImportBase64.Flags().String("base64", "", "")
if err := importCmd.RunE(badImportBase64, nil); err == nil {
t.Fatal("invalid import base64 flag should fail")
}
badForce := &cobra.Command{}
badForce.Flags().String("input", "x", "")
badForce.Flags().Bool("base64", false, "")
badForce.Flags().String("force", "", "")
if err := importCmd.RunE(badForce, nil); err == nil {
t.Fatal("invalid force flag should fail")
}
_, out, _ = authCoverageRoot(importCmd, "table", false)
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("missing input should fail")
}
_ = importCmd.Flags().Set("input", "bundle")
authPortableTargetPopulated = func(string) bool { return true }
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("populated target should require force")
}
authPortableTargetPopulated = func(string) bool { return false }
authReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("read failure should propagate")
}
authReadFile = func(string) ([]byte, error) { return []byte("%%%"), nil }
_ = importCmd.Flags().Set("base64", "true")
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("invalid base64 should fail")
}
authReadFile = func(string) ([]byte, error) { return []byte("YnVuZGxl"), nil }
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
return authpkg.PortableImportReport{}, errors.New("import")
}
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("import failure should propagate")
}
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
return authpkg.PortableImportReport{BundleOS: "other", OSMismatch: true}, nil
}
if err := importCmd.RunE(importCmd, nil); err != nil {
t.Fatal(err)
}
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
return authpkg.PortableImportReport{}, nil
}
if err := importCmd.RunE(importCmd, nil); err != nil {
t.Fatal(err)
}
exchange := newAuthExchangeCommand()
badCode := &cobra.Command{}
badCode.Flags().Bool("code", false, "")
if err := exchange.RunE(badCode, nil); err == nil {
t.Fatal("invalid code flag should fail")
}
badUID := &cobra.Command{}
badUID.Flags().String("code", "code", "")
badUID.Flags().Bool("uid", false, "")
if err := exchange.RunE(badUID, nil); err == nil {
t.Fatal("invalid uid flag should fail")
}
_, out, _ = authCoverageRoot(exchange, "table", false)
if err := exchange.RunE(exchange, nil); err == nil {
t.Fatal("missing code should fail")
}
_ = exchange.Flags().Set("code", "code")
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
return nil, errors.New("exchange")
}
if err := exchange.RunE(exchange, nil); err == nil {
t.Fatal("exchange error should propagate")
}
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{CorpID: "ding", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
_ = exchange.Flags().Set("uid", " user ")
if err := exchange.RunE(exchange, nil); err != nil || !strings.Contains(out.String(), "ding") {
t.Fatalf("exchange = %q, %v", out.String(), err)
}
reset := newAuthResetCommand()
_, out, _ = authCoverageRoot(reset, "table", false)
authDeleteAllTokenData = func(string) error { return errors.New("reset") }
if err := reset.RunE(reset, nil); err == nil {
t.Fatal("reset delete should fail")
}
removed := 0
authDeleteAllTokenData = func(string) error { return nil }
authRemove = func(string) error { removed++; return errors.New("ignored") }
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
edition.Override(&edition.Hooks{})
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
reset = newAuthResetCommand()
_, out, _ = authCoverageRoot(reset, "table", false)
if err := reset.RunE(reset, nil); err != nil || strings.Contains(out.String(), "重新登录") {
t.Fatalf("embedded reset = %q, %v", out.String(), err)
}
}
+284 -35
View File
@@ -23,6 +23,7 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -36,29 +37,33 @@ import (
)
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
sourceConfig := filepath.Join(t.TempDir(), ".dws")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
t.Setenv("DWS_CONFIG_DIR", sourceConfig)
originalSupported := authPortableExportSupported
originalReady := authPortableSourceReady
originalExport := authExportPortableBundle
originalTarget := authPortableTargetPopulated
originalImport := authImportPortableBundle
t.Cleanup(func() {
authPortableExportSupported = originalSupported
authPortableSourceReady = originalReady
authExportPortableBundle = originalExport
authPortableTargetPopulated = originalTarget
authImportPortableBundle = originalImport
})
original := &authpkg.TokenData{
AccessToken: "access-cli",
RefreshToken: "refresh-cli",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
ClientID: "client-cli",
Source: "mcp",
}
if err := authpkg.SaveTokenData(sourceConfig, original); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
bundle := []byte("portable-auth-bundle")
authPortableExportSupported = func() bool { return true }
authPortableSourceReady = func() bool { return true }
authExportPortableBundle = func(_ string, w io.Writer) error {
_, err := w.Write(bundle)
return err
}
exportCmd := NewRootCommand()
exportCmd := newAuthExportCommandWithSupport(func() error { return nil })
var exported bytes.Buffer
exportCmd.SetOut(&exported)
exportCmd.SetErr(&bytes.Buffer{})
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
exportCmd.SetArgs([]string{"--base64"})
if err := exportCmd.Execute(); err != nil {
t.Fatalf("auth export --base64 error = %v", err)
}
@@ -72,34 +77,245 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
t.Fatalf("write input bundle error = %v", err)
}
targetKeychain := filepath.Join(targetRoot, "target-keychain")
targetConfig := filepath.Join(targetRoot, ".dws")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
t.Setenv("DWS_CONFIG_DIR", targetConfig)
authPortableTargetPopulated = func(string) bool { return false }
var imported []byte
authImportPortableBundle = func(_ string, r io.Reader) (authpkg.PortableImportReport, error) {
var err error
imported, err = io.ReadAll(r)
return authpkg.PortableImportReport{}, err
}
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", inputPath, "--base64"})
if err := importCmd.Execute(); err != nil {
t.Fatalf("auth import --base64 error = %v", err)
}
if !bytes.Equal(imported, bundle) {
t.Fatalf("imported bundle = %q, want %q", imported, bundle)
}
}
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, "--base64"})
if err := importCmd.Execute(); err != nil {
t.Fatalf("auth import --base64 error = %v", err)
}
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath})
loaded, err := authpkg.LoadTokenData(targetConfig)
if err != nil {
t.Fatalf("LoadTokenData() after CLI import error = %v", err)
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import should reject the Windows DPAPI backend")
}
if loaded.RefreshToken != original.RefreshToken {
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if !loaded.IsRefreshTokenValid() {
t.Fatal("refresh token should remain valid after CLI import")
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 TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
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)
@@ -117,11 +333,42 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := NewRootCommand()
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
var stderr bytes.Buffer
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetArgs([]string{"auth", "import", "--input", bundlePath})
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 }
t.Cleanup(func() { authPortableTargetPopulated = originalTarget })
root := t.TempDir()
configDir := filepath.Join(root, ".dws")
t.Setenv("DWS_CONFIG_DIR", configDir)
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 })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", bundlePath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import without --force should fail when auth exists")
@@ -1073,9 +1320,11 @@ func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string
ResetRuntimeTokenCache()
clearCompatCache()
t.Cleanup(func() {
_ = authpkg.DeleteAllTokenData(configDir)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
CloseFileLogger()
})
for _, token := range tokens {
@@ -0,0 +1,70 @@
package app
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/spf13/cobra"
)
func TestAuthMigrateKeychainRemainingBranches(t *testing.T) {
originalMigrate, originalTarget := migrateKeychainToFileDEK, authMigrateTarget
t.Cleanup(func() {
migrateKeychainToFileDEK, authMigrateTarget = originalMigrate, originalTarget
})
newRoot := func(format string) (*cobra.Command, *bytes.Buffer) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("format", format, "")
root.AddCommand(newAuthMigrateKeychainCommand())
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
return root, &output
}
t.Setenv(keychain.DisableKeychainEnv, "")
authMigrateTarget = func(*cobra.Command) (string, error) { return "", errors.New("flag") }
root, _ := newRoot("text")
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--to") {
t.Fatalf("target flag error = %v", err)
}
authMigrateTarget = originalTarget
root, _ = newRoot("text")
root.SetArgs([]string{"migrate-keychain", "--to", "other", "--dry-run"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "file-dek") {
t.Fatalf("unsupported target error = %v", err)
}
migrateKeychainToFileDEK = func(string, bool) (int, error) { return 0, errors.New("backend") }
root, _ = newRoot("text")
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "backend") {
t.Fatalf("migration backend error = %v", err)
}
migrateKeychainToFileDEK = func(string, bool) (int, error) { return 3, nil }
for _, test := range []struct {
args []string
want string
}{
{[]string{"migrate-keychain", "--dry-run"}, "预检通过"},
{[]string{"migrate-keychain", "--yes"}, "迁移完成"},
} {
root, output := newRoot("text")
root.SetArgs(test.args)
if err := root.Execute(); err != nil || !strings.Contains(output.String(), test.want) {
t.Fatalf("migrate %v = %v, %q", test.args, err, output.String())
}
}
}
// Keep the original test name for the focused macOS auth workflow while also
// opting the coverage fixture into the native platform coverage gate.
func TestCrossPlatformCoverageAuthMigrateKeychainRemainingBranches(t *testing.T) {
TestAuthMigrateKeychainRemainingBranches(t)
}
+2 -4
View File
@@ -65,10 +65,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
case "", "json":
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, err := json.MarshalIndent(notice, "", " ")
if err != nil {
return err
}
data, _ := json.MarshalIndent(notice, "", " ")
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
+8 -5
View File
@@ -33,8 +33,11 @@ func init() {
// Build-time variables injected via ldflags when available.
var (
buildTime = "unknown"
gitCommit = "unknown"
buildTime = "unknown"
gitCommit = "unknown"
userHomeDir = os.UserHomeDir
executablePath = os.Executable
evaluateSymlink = filepath.EvalSymlinks
)
func defaultConfigDir() string {
@@ -44,7 +47,7 @@ func defaultConfigDir() string {
if fn := edition.Get().ConfigDir; fn != nil {
return fn()
}
homeDir, err := os.UserHomeDir()
homeDir, err := userHomeDir()
if err != nil {
return exeRelativeConfigDir()
}
@@ -52,11 +55,11 @@ func defaultConfigDir() string {
}
func exeRelativeConfigDir() string {
exePath, err := os.Executable()
exePath, err := executablePath()
if err != nil {
return ".dws"
}
realPath, err := filepath.EvalSymlinks(exePath)
realPath, err := evaluateSymlink(exePath)
if err != nil {
realPath = exePath
}
+3 -1
View File
@@ -7,7 +7,9 @@ import (
func TestDefaultConfigDirUsesHomeDirectoryInOSSMode(t *testing.T) {
homeDir := filepath.Join(t.TempDir(), "home")
t.Setenv("HOME", homeDir)
originalUserHomeDir := userHomeDir
userHomeDir = func() (string, error) { return homeDir, nil }
t.Cleanup(func() { userHomeDir = originalUserHomeDir })
t.Setenv("DWS_CONFIG_DIR", "")
got := defaultConfigDir()
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "initialize":
if !initOK {
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32601, "message": "init"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"protocolVersion": "2025-03-26"}})
case "tools/list":
if !listOK {
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -1, "message": "list"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"tools": []any{map[string]any{
"name": "tool",
"description": "desc",
"inputSchema": map[string]any{
"properties": map[string]any{"id": map[string]any{"type": "string"}},
"required": []any{"id", 1, ""},
},
}},
},
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{}})
}
}))
}
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
}
}
func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
oldProvider := newAccessTokenProvider
oldManager := newLegacyTokenManager
t.Cleanup(func() {
newAccessTokenProvider = oldProvider
newLegacyTokenManager = oldManager
})
cmd := &cobra.Command{Use: "api"}
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetContext(context.Background())
invalid := []struct {
args []string
gf GlobalFlags
af apiFlags
}{
{[]string{"GET", "/x?a=1"}, GlobalFlags{}, apiFlags{}},
{[]string{"TRACE", "/x"}, GlobalFlags{}, apiFlags{}},
{[]string{"GET", "../x"}, GlobalFlags{}, apiFlags{}},
{[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "x\x00"}},
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "x\x00"}},
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{params: "-", data: "-"}},
{[]string{"GET", "/x"}, GlobalFlags{Output: "x"}, apiFlags{pageAll: true}},
{[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "{"}},
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "{"}},
{[]string{"GET", "https://evil.test/x"}, GlobalFlags{Token: "t"}, apiFlags{}},
}
for _, tc := range invalid {
if err := runAPI(cmd, tc.args, &tc.gf, &tc.af); err == nil {
t.Fatalf("invalid API %#v succeeded", tc)
}
}
for _, raw := range []string{"", "a=1&empty=&=x", "a=1&b=2"} {
if got := parseQueryStringToJSON(raw); got == "" {
t.Fatalf("query JSON %q empty", raw)
}
}
if got, err := resolveRawAPIToken(context.Background(), " token "); err != nil || got != "token" {
t.Fatalf("explicit raw token = %q, %v", got, err)
}
authpkg.SetClientID("")
authpkg.SetClientSecret("")
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
t.Fatal("missing app credentials succeeded")
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "fail") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"bad"}`)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{1}, "hasMore": false})
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
host = strings.Split(host, ":")[0]
apiclient.AllowedHosts[host] = true
t.Cleanup(func() { delete(apiclient.AllowedHosts, host) })
gf := &GlobalFlags{Token: "token", DryRun: true, Format: "json", Timeout: 1}
af := &apiFlags{baseURL: server.URL}
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
t.Fatalf("API dry run = %q, %v", out.String(), err)
}
out.Reset()
gf.DryRun = false
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
t.Fatalf("API request = %q, %v", out.String(), err)
}
out.Reset()
af.pageAll = true
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
t.Fatalf("API pagination = %q, %v", out.String(), err)
}
client := apiclient.NewClient("token", server.URL)
if err := runPaginated(context.Background(), client, apiclient.RawAPIRequest{Method: "GET", Path: "/fail"}, &apiFlags{}, apiclient.ResponseOptions{Out: io.Discard, ErrOut: io.Discard}); err == nil {
t.Fatal("failed pagination succeeded")
}
if _, err := ResolveAuxiliaryAccessToken(context.Background(), "", ""); err == nil {
t.Fatal("empty auxiliary config succeeded")
}
if got, err := ResolveAuxiliaryAccessToken(context.Background(), "ignored", " explicit "); err != nil || got != "explicit" {
t.Fatalf("explicit auxiliary token = %q, %v", got, err)
}
dir := t.TempDir()
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{token: "saved"} }
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{} }
if got, err := resolveAccessTokenFromDir(context.Background(), dir); err != nil || got != "saved" {
t.Fatalf("saved access token = %q, %v", got, err)
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
missing := t.TempDir()
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
t.Fatalf("missing access token = %q, %v", got, err)
}
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
t.Fatal("missing auxiliary credentials succeeded")
}
if _, err := ForceRefreshAccessToken(context.Background(), ""); err == nil {
t.Fatal("empty force refresh config succeeded")
}
if _, err := ForceRefreshAccessToken(context.Background(), missing); err == nil {
t.Fatal("missing force refresh token succeeded")
}
}
func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = resolveVerbosity(nil)
for _, flags := range []struct {
debug bool
verbose bool
format string
json bool
}{{}, {verbose: true}, {debug: true}, {format: "json"}, {format: "table"}, {json: true}} {
flagCmd := &cobra.Command{Use: "flags"}
flagCmd.Flags().Bool("debug", flags.debug, "")
flagCmd.Flags().Bool("verbose", flags.verbose, "")
flagCmd.Flags().String("format", flags.format, "")
flagCmd.Flags().Bool("json", false, "")
if flags.json {
_ = flagCmd.Flags().Set("json", "true")
}
_ = resolveVerbosity(flagCmd)
_ = commandRequestsJSONErrors(flagCmd)
_ = wantsJSONErrors(flagCmd)
}
_ = commandRequestsJSONErrors(nil)
_ = wantsJSONErrors(nil)
if got, changed := normalizeProfileFlagArgs([]string{"--profile", "a,", "b"}); !changed || len(got) == 0 {
t.Fatalf("profile args = %#v, %v", got, changed)
}
if _, changed := normalizeProfileFlagArgs([]string{"--profile"}); changed {
t.Fatal("incomplete profile flag changed")
}
if preparseProfileFlag([]string{"--profile=a"}) != "a" || preparseProfileFlag([]string{"--profile", "b"}) != "b" || preparseProfileFlag(nil) != "" {
t.Fatal("profile preparse mismatch")
}
if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) {
t.Fatal("argsChanged mismatch")
}
cmd := &cobra.Command{Use: "root"}
cmd.SetContext(context.Background())
cmd.Flags().String("output", "", "")
if err := configureOutputSink(cmd); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "nested", "out.txt")
_ = cmd.Flags().Set("output", path)
if err := configureOutputSink(cmd); err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(cmd.OutOrStdout(), "data")
if err := closeOutputSink(cmd); err != nil {
t.Fatal(err)
}
local := &cobra.Command{Use: "local"}
local.SetContext(context.Background())
local.SetOut(io.Discard)
local.Flags().String("output", "", "")
if err := configureOutputSink(local); err != nil {
t.Fatal(err)
}
if err := validateOptionalPath("--x", ""); err != nil {
t.Fatal(err)
}
if err := validateOptionalPath("--x", "bad\x00path"); err == nil {
t.Fatal("unsafe path succeeded")
}
root := &cobra.Command{Use: "root", Short: "root"}
bindPersistentFlags(root, &GlobalFlags{})
root.AddCommand(&cobra.Command{Use: "alpha", Short: "alpha"}, &cobra.Command{Use: "hidden", Hidden: true})
configureRootHelp(root)
var help bytes.Buffer
root.SetOut(&help)
_ = root.Help()
renderRootGlobalFlags(root)
_ = visiblePersistentFlags(root)
for _, command := range root.Commands() {
_ = commandShort(command)
}
_ = visibleMCPRootCommands(root)
_ = visibleUtilityRootCommands(root)
tc := NewTimingCollector()
tc.Record("a", time.Microsecond)
tc.Record("b", 2*time.Second)
for _, d := range []time.Duration{time.Nanosecond, time.Microsecond, time.Millisecond, time.Second} {
_ = formatDuration(d)
}
for _, debug := range []bool{false, true} {
if debug {
t.Setenv(PerfDebugEnv, "1")
} else {
t.Setenv(PerfDebugEnv, "")
}
tc.PrintIfEnabled()
}
var timingOut bytes.Buffer
tc.Print(&timingOut)
if timingOut.Len() == 0 {
t.Fatal("timing output empty")
}
t.Setenv("HOME", t.TempDir())
if defaultPerfReportPath() == "" {
t.Fatal("default perf path empty")
}
t.Setenv(PerfReportEnv, "auto")
tc.WriteReportIfEnabled("v", "cmd")
if _, err := LoadLatestReport(); err != nil {
t.Fatal(err)
}
t.Setenv(PerfReportEnv, "")
tc.WriteReportIfEnabled("v", "cmd")
_ = exeRelativeConfigDir()
merged := mergeTopLevelCommands([]*cobra.Command{{Use: "a"}, {Use: "a"}, {Use: "b"}, nil})
if len(merged) != 2 {
t.Fatalf("merged commands = %#v", merged)
}
dedupRoot := &cobra.Command{Use: "root"}
dedupRoot.AddCommand(&cobra.Command{Use: "same"}, &cobra.Command{Use: "same"})
deduplicateCommands(dedupRoot)
addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
_ = newCompletionCommand(dedupRoot)
_ = newCatalogCommand(nil)
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
-3
View File
@@ -309,9 +309,6 @@ func editionServerEndpoint(productID string) (string, bool) {
return "", false
}
hooks := edition.Get()
if hooks == nil {
return "", false
}
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
return endpoint, true
}
+13 -7
View File
@@ -30,7 +30,14 @@ import (
"github.com/spf13/cobra"
)
var doctorKeychainDiagnose = keychain.Diagnose
var (
doctorKeychainDiagnose = keychain.Diagnose
doctorAuthStatus = (*authpkg.OAuthProvider).Status
doctorAuthAccessToken = (*authpkg.OAuthProvider).GetAccessToken
doctorHTTPDo = (*http.Client).Do
doctorFetchLatestRelease = func() (*upgrade.ReleaseInfo, error) { return upgrade.NewClient().FetchLatestRelease() }
doctorNeedsUpgrade = upgrade.NeedsUpgrade
)
// checkStatus represents the outcome of a single doctor check.
type checkStatus string
@@ -136,7 +143,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
data, err := provider.Status()
data, err := doctorAuthStatus(provider)
if err != nil || data == nil {
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
r := checkResult{
@@ -164,7 +171,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
if !data.IsAccessTokenValid() {
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
_, refreshErr := provider.GetAccessToken(refreshCtx)
_, refreshErr := doctorAuthAccessToken(provider, refreshCtx)
cancel()
if refreshErr != nil {
r := checkResult{
@@ -263,7 +270,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
resp, err := httpClient.Do(req)
resp, err := doctorHTTPDo(httpClient, req)
latency := time.Since(start)
if err != nil {
r := checkResult{
@@ -317,8 +324,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
currentVer := version
client := upgrade.NewClient()
latest, err := client.FetchLatestRelease()
latest, err := doctorFetchLatestRelease()
if err != nil {
r := checkResult{
Name: "version",
@@ -332,7 +338,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
return r
}
if upgrade.NeedsUpgrade(currentVer, latest.Version) {
if doctorNeedsUpgrade(currentVer, latest.Version) {
r := checkResult{
Name: "version",
Status: statusWarn,
+133
View File
@@ -0,0 +1,133 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageDoctorRemainingCoverage(t *testing.T) {
oldEdition := edition.Get()
oldDiagnose := doctorKeychainDiagnose
oldStatus := doctorAuthStatus
oldAccess := doctorAuthAccessToken
oldHTTP := doctorHTTPDo
oldLatest := doctorFetchLatestRelease
oldNeeds := doctorNeedsUpgrade
oldRead := timingReadFile
t.Cleanup(func() {
edition.Override(oldEdition)
doctorKeychainDiagnose = oldDiagnose
doctorAuthStatus = oldStatus
doctorAuthAccessToken = oldAccess
doctorHTTPDo = oldHTTP
doctorFetchLatestRelease = oldLatest
doctorNeedsUpgrade = oldNeeds
timingReadFile = oldRead
})
edition.Override(&edition.Hooks{})
doctorKeychainDiagnose = func() keychain.Diagnostic { return keychain.Diagnostic{OK: true, Message: "ok"} }
buf := &bytes.Buffer{}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
t.Fatalf("missing auth = %#v", got)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
if got := doctorCheckAuth(context.Background(), io.Discard, true); got.Status != statusFail || got.Hint != "" {
t.Fatalf("embedded missing auth = %#v", got)
}
edition.Override(&edition.Hooks{})
now := time.Now()
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour)}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
t.Fatalf("valid auth = %#v", got)
}
refresh := &authpkg.TokenData{RefreshToken: "r", RefreshExpAt: now.Add(time.Hour)}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return refresh, nil }
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusWarn {
t.Fatalf("refresh failure = %#v", got)
}
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "a", nil }
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
t.Fatalf("refresh success = %#v", got)
}
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour)}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
t.Fatalf("expired auth = %#v", got)
}
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(":"), 0o600); err != nil {
t.Fatal(err)
}
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
t.Fatalf("invalid network URL = %#v", got)
}
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://example.test"), 0o600); err != nil {
t.Fatal(err)
}
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, errors.New("network") }
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
t.Fatalf("network failure = %#v", got)
}
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
}
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusPass {
t.Fatalf("network success = %#v", got)
}
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return nil, errors.New("latest") }
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusFail {
t.Fatalf("version failure = %#v", got)
}
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: "99.0.0"}, nil }
doctorNeedsUpgrade = func(string, string) bool { return true }
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusWarn {
t.Fatalf("version warning = %#v", got)
}
doctorNeedsUpgrade = func(string, string) bool { return false }
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusPass {
t.Fatalf("version pass = %#v", got)
}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: version}, nil }
timingReadFile = func(string) ([]byte, error) {
return []byte(`{"command":"test","timestamp":"2026-01-01T00:00:00Z","phases":[]}`), nil
}
doctor := newDoctorCommand()
doctor.SetContext(context.Background())
doctor.SetOut(io.Discard)
_ = doctor.Flags().Set("json", "true")
_ = doctor.Flags().Set("perf", "true")
_ = doctor.Flags().Set("timeout", "0")
if err := doctor.RunE(doctor, nil); err != nil {
t.Fatal(err)
}
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, errors.New("not logged in") }
doctor = newDoctorCommand()
doctor.SetContext(context.Background())
doctor.SetOut(io.Discard)
if err := doctor.RunE(doctor, nil); err == nil {
t.Fatal("doctor failures should return an error")
}
}
+67 -38
View File
@@ -44,6 +44,31 @@ import (
"github.com/spf13/cobra"
)
var (
eventRunPersonalConsume = runPersonalEventConsume
eventRunPersonalList = runPersonalEventList
eventRunPersonalStatus = runPersonalEventStatus
eventRunPersonalStop = runPersonalEventStop
eventNormalizeAs = normalizeEventAs
eventResolveCredentials = resolveEventCredentials
eventConsumeRun = consume.Run
eventRunForeground = runForegroundBus
eventNewEventSource = newEventSource
eventNewDingtalkSource = source.New
eventResolveAccessToken = ResolveAuxiliaryAccessToken
eventBusRun = bus.Run
eventReadyFDFromEnv = busctl.ReadyFDFromEnv
eventResolvePersonal = resolvePersonalEventIdentity
eventNewPersonalSource = newPersonalStreamSource
eventMkdirAll = os.MkdirAll
eventOpenFile = os.OpenFile
eventEnumerateBuses = busctl.EnumerateBuses
eventFindBus = busctl.FindBusByClientID
eventQueryEntry = busctl.QueryEntry
eventStopBus = busctl.Stop
eventResolveAppCredentials = authpkg.ResolveAppCredentialsStrict
)
// newEventCommand returns the `event` parent command and all its subcommands.
// Wired into root.go's utilityCommands list.
func newEventCommand() *cobra.Command {
@@ -112,7 +137,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
@@ -135,7 +160,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
personalOpts.StreamTicketMode = streamOpts.Mode
personalOpts.StreamTicketURL = streamOpts.TicketURL
personalOpts.StreamSourceID = streamOpts.SourceID
return runPersonalEventConsume(c, personalOpts)
return eventRunPersonalConsume(c, personalOpts)
}
if personalOpts.DebugRawEvents {
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
@@ -164,7 +189,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
// Portal ticket normal mode uses portal-managed app credentials, so
// local ClientSecret is intentionally not required there.
configDir := defaultConfigDir()
clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts)
clientID, clientSecret, err := eventResolveCredentials(configDir, streamOpts)
if err != nil {
return fmt.Errorf("event consume: %w", err)
}
@@ -221,9 +246,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style,
// unbounded run (see shouldWatchStdinEOF).
if shouldWatchStdinEOF(maxEvents, duration) {
cfg.Stdin = c.InOrStdin()
}
applyEventConsumeStdin(&cfg, maxEvents, duration, c.InOrStdin())
// Step 5: validation (flag-only rules).
if err := consume.ValidateConfig(cfg); err != nil {
@@ -239,9 +262,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
// Step 6: foreground mode runs the bus in-process. Otherwise
// consume.Run discovers / forks the bus and dials it.
if foreground {
return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts)
return eventRunForeground(ctx, cfg, configDir, clientSecret, streamOpts)
}
return consume.Run(ctx, cfg)
return eventConsumeRun(ctx, cfg)
},
}
@@ -324,7 +347,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
// can run `dws event consume` from another shell to consume the events.
// v2 may add a "foreground + in-process consumer" combined mode.
func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error {
src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
src, err := eventNewEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
if err != nil {
return err
}
@@ -339,7 +362,7 @@ func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, client
Logger: slog.Default(),
}
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
return eventBusRun(ctx, busCfg)
}
type eventStreamTicketOptions struct {
@@ -389,13 +412,13 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
if !streamOpts.enabled() {
return source.New(source.Config{
return eventNewDingtalkSource(source.Config{
ClientID: clientID,
ClientSecret: clientSecret,
})
}
token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
token, err := eventResolveAccessToken(ctx, configDir, "")
if err != nil {
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
}
@@ -410,7 +433,7 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
portalClientSecret = ""
}
return source.New(source.Config{
return eventNewDingtalkSource(source.Config{
ClientID: portalClientID,
ClientSecret: portalClientSecret,
PortalTicket: &source.PortalTicketConfig{
@@ -474,7 +497,7 @@ func newEventBusCommand() *cobra.Command {
// Acquire ReadyPipe early so pre-bus.Run failures can signal
// 'E' to the parent process instead of silently dying.
readyPipe := busctl.ReadyFDFromEnv()
readyPipe := eventReadyFDFromEnv()
failEarly := func(err error) error {
if readyPipe != nil {
// 'E' signals failure; the trailing text lets the parent
@@ -495,7 +518,7 @@ func newEventBusCommand() *cobra.Command {
sourceKind = dwsevent.SourceKindAppStream
}
if sourceKind == dwsevent.SourceKindPersonalStream {
identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID)
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
@@ -506,7 +529,7 @@ func newEventBusCommand() *cobra.Command {
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: streamOpts.Mode,
@@ -516,8 +539,8 @@ func newEventBusCommand() *cobra.Command {
if err != nil {
return failEarly(err)
}
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
defer lf.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
@@ -537,10 +560,10 @@ func newEventBusCommand() *cobra.Command {
Logger: slog.Default(),
}
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
return eventBusRun(ctx, busCfg)
}
resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts)
resolvedID, secret, err := eventResolveCredentials(configDir, streamOpts)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
@@ -553,7 +576,7 @@ func newEventBusCommand() *cobra.Command {
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts)
src, err := eventNewEventSource(ctx, configDir, clientID, secret, streamOpts)
if err != nil {
return failEarly(err)
}
@@ -562,8 +585,8 @@ func newEventBusCommand() *cobra.Command {
// own log lines never pollute stdout/stderr (which busctl/Spawn
// detached). Best-effort: if mkdir / open fails we fall back
// to slog.Default (stderr) so we at least see startup errors.
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
defer lf.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
@@ -585,7 +608,7 @@ func newEventBusCommand() *cobra.Command {
// env-var tuning (only fills in fields left at zero; explicit
// flags above keep precedence).
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
return eventBusRun(ctx, busCfg)
},
}
cmd.Flags().StringVar(&clientIDOverride, "client-id", "",
@@ -642,7 +665,7 @@ func newEventListCommand() *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
as, err := normalizeEventAs(asIdentity)
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
@@ -650,7 +673,7 @@ func newEventListCommand() *cobra.Command {
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil {
return fmt.Errorf("event list: %w", err)
}
return runPersonalEventList(c, personalListOptions{
return eventRunPersonalList(c, personalListOptions{
Category: category,
EnabledOnly: enabledOnly,
IncludePending: includePending,
@@ -704,7 +727,7 @@ func newEventStatusCommand() *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
as, err := normalizeEventAs(asIdentity)
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
@@ -713,7 +736,7 @@ func newEventStatusCommand() *cobra.Command {
return fmt.Errorf("event status: %w", err)
}
personalOpts.Format = formatRaw
return runPersonalEventStatus(c, personalOpts)
return eventRunPersonalStatus(c, personalOpts)
}
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event status: %w", err)
@@ -764,14 +787,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
// --all-editions trumps --all (scan whole tree)
if allEditions {
entries, err := busctl.EnumerateBuses(configDir, "")
entries, err := eventEnumerateBuses(configDir, "")
if err != nil {
return nil, err
}
return queryAll(entries), nil
}
if all {
entries, err := busctl.EnumerateBuses(configDir, editionName)
entries, err := eventEnumerateBuses(configDir, editionName)
if err != nil {
return nil, err
}
@@ -782,14 +805,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
// otherwise resolve via strict resolver.
clientID := clientIDOver
if clientID == "" {
resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
resolved, _, _, _, err := eventResolveAppCredentials(configDir)
if err != nil {
return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err)
}
clientID = resolved
}
hash := dwsevent.ClientIDHash(clientID)
entry := busctl.FindBusByClientID(configDir, editionName, hash)
entry := eventFindBus(configDir, editionName, hash)
if entry == nil {
// No directory at all — render an empty "not running" so the user
// sees a useful answer instead of an error.
@@ -813,13 +836,13 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
if entry.Meta == nil {
entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName}
}
return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil
return []busctl.EntryStatus{eventQueryEntry(*entry)}, nil
}
func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus {
out := make([]busctl.EntryStatus, 0, len(entries))
for _, e := range entries {
out = append(out, busctl.QueryEntry(e))
out = append(out, eventQueryEntry(e))
}
return out
}
@@ -989,7 +1012,7 @@ func newEventStopCommand() *cobra.Command {
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
@@ -1008,7 +1031,7 @@ func newEventStopCommand() *cobra.Command {
if !eventStopConfirmed(c) {
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
}
return runPersonalEventStop(c, opts)
return eventRunPersonalStop(c, opts)
}
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event stop: %w", err)
@@ -1023,14 +1046,14 @@ func newEventStopCommand() *cobra.Command {
return eventStopConfirmationRequired("event stop 会停止事件消费")
}
configDir := defaultConfigDir()
clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
if err != nil {
return fmt.Errorf("event stop: %w", err)
}
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
return nil
@@ -1199,6 +1222,12 @@ func shouldWatchStdinEOF(maxEvents int, duration time.Duration) bool {
return fi.Mode()&os.ModeCharDevice == 0
}
func applyEventConsumeStdin(cfg *consume.Config, maxEvents int, duration time.Duration, stdin io.Reader) {
if cfg != nil && shouldWatchStdinEOF(maxEvents, duration) {
cfg.Stdin = stdin
}
}
// eventTypesWithDefault picks the catch-all list from registry when the
// user did not pass --event-types.
func eventTypesWithDefault(types []string) []string {
@@ -0,0 +1,558 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventConsumeCommandAllBranchesCoverage(t *testing.T) {
oldPersonal := eventRunPersonalConsume
oldCreds, oldConsume, oldForeground := eventResolveCredentials, eventConsumeRun, eventRunForeground
oldNormalize := eventNormalizeAs
t.Cleanup(func() {
eventRunPersonalConsume = oldPersonal
eventResolveCredentials, eventConsumeRun, eventRunForeground = oldCreds, oldConsume, oldForeground
eventNormalizeAs = oldNormalize
})
eventNormalizeAs = func(value string) (string, error) {
if strings.EqualFold(strings.TrimSpace(value), "app") {
return "app", nil
}
return normalizeEventAs(value)
}
fail := errors.New("failure")
personalCalled := false
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error { personalCalled = true; return nil }
cmd := newEventConsumeCommand()
cmd.SetContext(context.Background())
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.RunE(cmd, []string{"event.key"}); err != nil || !personalCalled {
t.Fatalf("personal consume = %v, called=%v", err, personalCalled)
}
makeApp := func() *cobra.Command {
command := newEventConsumeCommand()
command.SetContext(context.Background())
command.SetOut(io.Discard)
command.SetErr(io.Discard)
_ = command.Flags().Set("as", "app")
return command
}
invalid := newEventConsumeCommand()
_ = invalid.Flags().Set("as", "invalid")
if err := invalid.RunE(invalid, nil); err == nil {
t.Fatal("invalid event identity succeeded")
}
debug := makeApp()
_ = debug.Flags().Set("debug-raw-events", "true")
if err := debug.RunE(debug, nil); err == nil {
t.Fatal("app debug-raw-events succeeded")
}
userFlag := makeApp()
_ = userFlag.Flags().Set("subscribe-id", "sub")
if err := userFlag.RunE(userFlag, nil); err == nil {
t.Fatal("app personal flag succeeded")
}
if err := makeApp().RunE(makeApp(), []string{"event.key"}); err == nil {
t.Fatal("app event key succeeded")
}
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
if err := makeApp().RunE(makeApp(), nil); !errors.Is(err, fail) {
t.Fatalf("event credentials error = %v", err)
}
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
badRoute := makeApp()
_ = badRoute.Flags().Set("route", "bad")
if err := badRoute.RunE(badRoute, nil); err == nil {
t.Fatal("invalid event route succeeded")
}
invalidConfig := makeApp()
_ = invalidConfig.Flags().Set("format", "json")
if err := invalidConfig.RunE(invalidConfig, nil); err == nil {
t.Fatal("unbounded JSON event stream succeeded")
}
conflict := makeApp()
conflict.Flags().String("output", "", "")
_ = conflict.Flags().Set("output-dir", t.TempDir())
_ = conflict.Flags().Set("output", filepath.Join(t.TempDir(), "out"))
if err := conflict.RunE(conflict, nil); err == nil {
t.Fatal("event output conflict succeeded")
}
eventConsumeRun = func(context.Context, consume.Config) error { return fail }
t.Setenv(authpkg.EnvClientID, "half-set")
t.Setenv(authpkg.EnvClientSecret, "")
valid := makeApp()
_ = valid.Flags().Set("format", "table")
_ = valid.Flags().Set("max-events", "1")
if err := valid.RunE(valid, nil); !errors.Is(err, fail) {
t.Fatalf("event consume run error = %v", err)
}
eventRunForeground = func(context.Context, consume.Config, string, string, eventStreamTicketOptions) error { return fail }
foreground := makeApp()
_ = foreground.Flags().Set("foreground", "true")
if err := foreground.RunE(foreground, nil); !errors.Is(err, fail) {
t.Fatalf("foreground event error = %v", err)
}
}
func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
oldNew, oldToken, oldEventSource, oldBus := eventNewDingtalkSource, eventResolveAccessToken, eventNewEventSource, eventBusRun
oldEdition := edition.Get()
t.Cleanup(func() {
eventNewDingtalkSource, eventResolveAccessToken = oldNew, oldToken
eventNewEventSource, eventBusRun = oldEventSource, oldBus
edition.Override(oldEdition)
})
fail := errors.New("failure")
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) { return nil, fail }
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
t.Fatalf("SDK event source error = %v", err)
}
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) {
return &source.DingtalkSource{}, nil
}
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
t.Fatal(err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
stream := eventStreamTicketOptions{Mode: "custom"}
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
t.Fatalf("stream token error = %v", err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
t.Fatal("empty stream token succeeded")
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
for _, mode := range []string{"custom", "normal"} {
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{Mode: mode}); err != nil {
t.Fatalf("stream source %s = %v", mode, err)
}
}
edition.Override(&edition.Hooks{Name: "test"})
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
return nil, fail
}
if err := runForegroundBus(context.Background(), consume.Config{}, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
t.Fatalf("foreground source error = %v", err)
}
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
return &source.DingtalkSource{}, nil
}
eventBusRun = func(context.Context, bus.Config) error { return fail }
cfg := consume.Config{WorkDir: t.TempDir(), IPCEndpoint: filepath.Join(t.TempDir(), "bus.sock"), ClientID: "client"}
if err := runForegroundBus(context.Background(), cfg, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
t.Fatalf("foreground bus error = %v", err)
}
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.example.test"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DWS_STREAM_SOURCE_ID", "")
if got := defaultEventStreamSourceID(); got != "pre_open_source" {
t.Fatalf("pre stream source ID = %q", got)
}
}
func TestCrossPlatformCoverageEventBusCommandAllBranchesCoverage(t *testing.T) {
oldReady, oldPersonal, oldPersonalSource := eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource
oldCreds, oldSource, oldRun := eventResolveCredentials, eventNewEventSource, eventBusRun
oldMkdir, oldOpen := eventMkdirAll, eventOpenFile
t.Cleanup(func() {
eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource = oldReady, oldPersonal, oldPersonalSource
eventResolveCredentials, eventNewEventSource, eventBusRun = oldCreds, oldSource, oldRun
eventMkdirAll, eventOpenFile = oldMkdir, oldOpen
})
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
fail := errors.New("failure")
makeBus := func(kind string) *cobra.Command {
cmd := newEventBusCommand()
cmd.SetContext(context.Background())
_ = cmd.Flags().Set("source-kind", kind)
return cmd
}
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
eventReadyFDFromEnv = func() *os.File { return write }
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, fail }
if err := makeBus(string(dwsevent.SourceKindPersonalStream)).RunE(makeBus(string(dwsevent.SourceKindPersonalStream)), nil); !errors.Is(err, fail) {
t.Fatalf("personal identity error = %v", err)
}
marker := make([]byte, 1)
_, _ = read.Read(marker)
_ = read.Close()
if marker[0] != 'E' {
t.Fatalf("ready failure marker = %q", marker)
}
eventReadyFDFromEnv = func() *os.File { return nil }
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, fail }
personalCmd := makeBus(string(dwsevent.SourceKindPersonalStream))
_ = personalCmd.Flags().Set("client-id", "override")
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
t.Fatalf("personal stream source error = %v", err)
}
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return &source.PersonalSource{}, nil
}
eventMkdirAll = func(string, os.FileMode) error { return nil }
eventOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "bus-log") }
eventBusRun = func(context.Context, bus.Config) error { return fail }
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
t.Fatalf("personal bus run error = %v", err)
}
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
if err := makeBus(string(dwsevent.SourceKindAppStream)).RunE(makeBus(string(dwsevent.SourceKindAppStream)), nil); !errors.Is(err, fail) {
t.Fatalf("app bus credentials error = %v", err)
}
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
return nil, fail
}
appCmd := makeBus("")
_ = appCmd.Flags().Set("client-id", "override")
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
t.Fatalf("app event source error = %v", err)
}
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
return &source.DingtalkSource{}, nil
}
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
t.Fatalf("app bus run error = %v", err)
}
eventMkdirAll = func(string, os.FileMode) error { return fail }
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
t.Fatalf("app bus run after log mkdir failure = %v", err)
}
}
func TestCrossPlatformCoverageEventListStatusCollectAndStopCoverage(t *testing.T) {
oldList, oldStatus, oldStopPersonal := eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop
oldEnum, oldFind, oldQuery, oldStop := eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus
oldCreds := eventResolveAppCredentials
oldNormalize := eventNormalizeAs
t.Cleanup(func() {
eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop = oldList, oldStatus, oldStopPersonal
eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus = oldEnum, oldFind, oldQuery, oldStop
eventResolveAppCredentials = oldCreds
eventNormalizeAs = oldNormalize
})
eventNormalizeAs = func(value string) (string, error) {
if strings.EqualFold(strings.TrimSpace(value), "app") {
return "app", nil
}
return normalizeEventAs(value)
}
fail := errors.New("failure")
eventRunPersonalList = func(*cobra.Command, personalListOptions) error { return fail }
list := newEventListCommand()
list.SetOut(io.Discard)
if err := list.RunE(list, nil); !errors.Is(err, fail) {
t.Fatalf("personal list error = %v", err)
}
eventRunPersonalStatus = func(*cobra.Command, personalStatusOptions) error { return fail }
status := newEventStatusCommand()
status.SetOut(io.Discard)
if err := status.RunE(status, nil); !errors.Is(err, fail) {
t.Fatalf("personal status error = %v", err)
}
eventRunPersonalStop = func(*cobra.Command, personalStopOptions) error { return fail }
stop := newEventStopCommand()
stop.SetOut(io.Discard)
stop.Flags().Bool("yes", false, "")
_ = stop.Flags().Set("yes", "true")
if err := stop.RunE(stop, []string{"sub"}); !errors.Is(err, fail) {
t.Fatalf("personal stop error = %v", err)
}
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) { return nil, fail }
if _, err := collectEntries(&cobra.Command{}, "client", false, true); !errors.Is(err, fail) {
t.Fatalf("all-editions collect error = %v", err)
}
if _, err := collectEntries(&cobra.Command{}, "client", true, false); !errors.Is(err, fail) {
t.Fatalf("all collect error = %v", err)
}
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) {
return []busctl.BusEntry{{ClientIDHash: "hash"}}, nil
}
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
if got, err := collectEntries(&cobra.Command{}, "client", false, true); err != nil || len(got) != 1 {
t.Fatalf("all-editions entries = %#v, %v", got, err)
}
if got, err := collectEntries(&cobra.Command{}, "client", true, false); err != nil || len(got) != 1 {
t.Fatalf("all entries = %#v, %v", got, err)
}
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
}
if _, err := collectEntries(&cobra.Command{}, "", false, false); !errors.Is(err, fail) {
t.Fatalf("single credentials error = %v", err)
}
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
}
eventFindBus = func(string, string, string) *busctl.BusEntry { return nil }
if got, err := collectEntries(&cobra.Command{}, "", false, false); err != nil || len(got) != 1 || got[0].Entry.State != busctl.BusStateNotRunning {
t.Fatalf("missing bus entry = %#v, %v", got, err)
}
eventFindBus = func(string, string, string) *busctl.BusEntry { return &busctl.BusEntry{} }
if got, err := collectEntries(&cobra.Command{}, "client", false, false); err != nil || len(got) != 1 || got[0].Entry.Meta == nil {
t.Fatalf("found bus entry = %#v, %v", got, err)
}
appList := newEventListCommand()
appList.SetOut(io.Discard)
_ = appList.Flags().Set("as", "app")
_ = appList.Flags().Set("client-id", "client")
if err := appList.RunE(appList, nil); err != nil {
t.Fatal(err)
}
rejectedList := newEventListCommand()
_ = rejectedList.Flags().Set("as", "app")
_ = rejectedList.Flags().Set("category", "chat")
if err := rejectedList.RunE(rejectedList, nil); err == nil {
t.Fatal("app list personal flag succeeded")
}
eventFindBus = func(string, string, string) *busctl.BusEntry {
return &busctl.BusEntry{State: busctl.BusStateOrphan, Meta: &bus.Meta{ClientID: "client"}}
}
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
appStatus := newEventStatusCommand()
appStatus.SetOut(io.Discard)
_ = appStatus.Flags().Set("as", "app")
_ = appStatus.Flags().Set("client-id", "client")
_ = appStatus.Flags().Set("fail-on-orphan", "true")
if err := appStatus.RunE(appStatus, nil); err == nil {
t.Fatal("orphan event status succeeded")
}
rejectedStatus := newEventStatusCommand()
_ = rejectedStatus.Flags().Set("as", "app")
_ = rejectedStatus.Flags().Set("event", "key")
if err := rejectedStatus.RunE(rejectedStatus, nil); err == nil {
t.Fatal("app status personal flag succeeded")
}
appStop := func() *cobra.Command {
cmd := newEventStopCommand()
cmd.SetOut(io.Discard)
cmd.Flags().Bool("yes", true, "")
_ = cmd.Flags().Set("as", "app")
return cmd
}
changedStop := appStop()
_ = changedStop.Flags().Set("all", "true")
if err := changedStop.RunE(changedStop, nil); err == nil {
t.Fatal("app stop personal flag succeeded")
}
if err := appStop().RunE(appStop(), []string{"sub"}); err == nil {
t.Fatal("app subscribe ID succeeded")
}
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
}
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
t.Fatalf("app stop credentials error = %v", err)
}
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
}
eventStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
if err := appStop().RunE(appStop(), nil); err != nil {
t.Fatalf("already stopped bus = %v", err)
}
eventStopBus = func(busctl.StopConfig) error { return fail }
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
t.Fatalf("stop bus error = %v", err)
}
eventStopBus = func(busctl.StopConfig) error { return nil }
if err := appStop().RunE(appStop(), nil); err != nil {
t.Fatalf("stop bus success = %v", err)
}
}
func TestCrossPlatformCoverageEventCommandParentCoverage(t *testing.T) {
cmd := newEventCommand()
cmd.SetOut(io.Discard)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(cmd.Use, "event") {
t.Fatal("event command use changed")
}
}
func TestCrossPlatformCoverageEventCommandPureAndRenderBranchesCoverage(t *testing.T) {
t.Setenv(authpkg.EnvClientID, "client")
t.Setenv(authpkg.EnvClientSecret, "secret")
if got := (eventStreamTicketOptions{Mode: "custom", SourceID: " source ", TicketURL: " https://ticket "}).spawnArgs(); len(got) != 6 {
t.Fatalf("stream spawn args = %#v", got)
}
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{}); err != nil || id != "client" || secret != "secret" {
t.Fatalf("app credentials = %q, %q, %v", id, secret, err)
}
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{Mode: "normal", SourceID: "source"}); err != nil || id != "portal-ticket-normal:source" || secret != "" {
t.Fatalf("portal credentials = %q, %q, %v", id, secret, err)
}
if eventStreamTicketURL(" https://ticket ") != "https://ticket" || eventStreamSourceID(" source ") != "source" {
t.Fatal("explicit event stream routing changed")
}
t.Setenv("DWS_STREAM_SOURCE_ID", "environment")
if defaultEventStreamSourceID() != "environment" {
t.Fatal("environment stream source ID ignored")
}
oldEdition := edition.Get()
edition.Override(&edition.Hooks{})
t.Cleanup(func() { edition.Override(oldEdition) })
if editionNameOrDefault() != "open" || sourceKindLabel("") != string(dwsevent.SourceKindAppStream) {
t.Fatal("default event labels changed")
}
if !strings.Contains(eventWorkDir("config", "open", "", "hash"), string(dwsevent.SourceKindAppStream)) {
t.Fatal("default event workdir changed")
}
if _, err := normalizeEventAs("bot"); err == nil {
t.Fatal("bot events became public unexpectedly")
}
flags := &cobra.Command{}
flags.Flags().Bool("all", false, "")
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err != nil {
t.Fatal(err)
}
_ = flags.Flags().Set("all", "true")
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err == nil {
t.Fatal("personal unsupported flag accepted")
}
if firstArg(nil) != "" || firstArg([]string{"value"}) != "value" || len(eventTypesWithDefault([]string{"type"})) != 1 {
t.Fatal("event helper defaults changed")
}
_ = eventTypesWithDefault(nil)
live := &eventtransport.StatusResp{
Bus: eventtransport.StatusBus{UptimeSecs: 12},
SourceState: eventtransport.StatusSource{State: "connected", Source: "hook", ReconnectCount: 1},
Consumers: []eventtransport.StatusConsumer{
{PID: 1, Received: 2, Dropped: 3},
{PID: 2, EventTypes: []string{"chat"}, SubscribeID: "sub"},
},
PerEventTypeCounters: map[string]eventtransport.Counters{"chat": {Received: 2, Dropped: 1}},
}
entries := []busctl.EntryStatus{
{Entry: busctl.BusEntry{State: busctl.BusStateNotRunning, ClientIDHash: "not-running"}},
{Entry: busctl.BusEntry{State: busctl.BusStateOrphan, HolderPID: 3, Meta: &bus.Meta{ClientID: "orphan", SourceID: "source", StartedAt: time.Now()}}},
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 4, Meta: &bus.Meta{ClientID: "offline", StartedAt: time.Now().Add(-time.Minute)}}},
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 5, Meta: &bus.Meta{ClientID: "live"}}, Live: live},
}
if err := renderStatus(io.Discard, entries, "text"); err != nil {
t.Fatal(err)
}
if err := renderStatus(io.Discard, entries, "json"); err != nil {
t.Fatal(err)
}
if err := renderStatus(appFailWriter{err: errors.New("write")}, entries, "json"); err == nil {
t.Fatal("status JSON write failure succeeded")
}
listEntries := []listEntry{
{ClientIDHash: "hash", BusState: busctl.BusStateNotRunning},
{ClientID: "client", SourceKind: dwsevent.SourceKindPersonalStream, BusState: busctl.BusStateRunning, BusPID: 2, Consumers: live.Consumers},
}
if err := renderList(io.Discard, listEntries, "table"); err != nil {
t.Fatal(err)
}
if err := renderList(io.Discard, listEntries, "json"); err != nil {
t.Fatal(err)
}
if err := renderList(appFailWriter{err: errors.New("write")}, listEntries, "json"); err == nil {
t.Fatal("list JSON write failure succeeded")
}
if got := buildListEntry(busctl.EntryStatus{Entry: busctl.BusEntry{Meta: &bus.Meta{ClientID: "client"}}, Live: live}); len(got.Consumers) != 2 {
t.Fatalf("live list entry = %#v", got)
}
}
func TestCrossPlatformCoverageEventCommandClosureErrorBranchesCoverage(t *testing.T) {
oldNormalize := eventNormalizeAs
oldList, oldStatus := eventRunPersonalList, eventRunPersonalStatus
oldCreds, oldFind, oldQuery := eventResolveAppCredentials, eventFindBus, eventQueryEntry
t.Cleanup(func() {
eventNormalizeAs = oldNormalize
eventRunPersonalList, eventRunPersonalStatus = oldList, oldStatus
eventResolveAppCredentials, eventFindBus, eventQueryEntry = oldCreds, oldFind, oldQuery
})
eventNormalizeAs = func(value string) (string, error) {
if strings.TrimSpace(value) == "app" {
return "app", nil
}
return normalizeEventAs(value)
}
personalList := newEventListCommand()
_ = personalList.Flags().Set("all", "true")
if err := personalList.RunE(personalList, nil); err == nil {
t.Fatal("personal list app flag succeeded")
}
personalStatus := newEventStatusCommand()
_ = personalStatus.Flags().Set("fail-on-orphan", "true")
if err := personalStatus.RunE(personalStatus, nil); err == nil {
t.Fatal("personal status app flag succeeded")
}
fail := errors.New("failure")
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
}
appList := newEventListCommand()
_ = appList.Flags().Set("as", "app")
if err := appList.RunE(appList, nil); !errors.Is(err, fail) {
t.Fatalf("app list collect error = %v", err)
}
appStatus := newEventStatusCommand()
_ = appStatus.Flags().Set("as", "app")
if err := appStatus.RunE(appStatus, nil); !errors.Is(err, fail) {
t.Fatalf("app status collect error = %v", err)
}
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
}
eventFindBus = func(string, string, string) *busctl.BusEntry {
return &busctl.BusEntry{State: busctl.BusStateRunning, Meta: &bus.Meta{ClientID: "client"}}
}
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
appStatus = newEventStatusCommand()
appStatus.SetOut(io.Discard)
_ = appStatus.Flags().Set("as", "app")
_ = appStatus.Flags().Set("fail-on-orphan", "true")
if err := appStatus.RunE(appStatus, nil); err != nil {
t.Fatalf("healthy status with orphan gate = %v", err)
}
appStatus = newEventStatusCommand()
appStatus.SetOut(appFailWriter{err: errors.New("write")})
_ = appStatus.Flags().Set("as", "app")
_ = appStatus.Flags().Set("format", "json")
if err := appStatus.RunE(appStatus, nil); err == nil {
t.Fatal("status render failure should propagate")
}
}
+61 -42
View File
@@ -107,6 +107,33 @@ type personalStreamSourceOptions struct {
ClientIDOverride string
}
var (
personalResolveEventIdentity = resolvePersonalEventIdentity
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = (*personal.Client).GetSubscription
personalCreateSubscription = (*personal.Client).CreateSubscription
personalDeleteSubscription = (*personal.Client).DeleteSubscription
personalListSubscriptions = (*personal.Client).ListSubscriptions
personalUpsertRunState = personal.UpsertRunState
personalRemoveRunStates = personal.RemoveRunStates
personalLoadRunStates = personal.LoadRunStates
personalConsumeRun = consume.Run
personalValidateConsumeConfig = consume.ValidateConfig
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
personalNewStreamSource = newPersonalStreamSource
personalBusRun = bus.Run
personalFindBusByIdentity = busctl.FindBusByIdentity
personalQueryEntry = busctl.QueryEntry
personalQueryStatus = busctl.QueryStatus
personalStopBus = busctl.Stop
personalFindProcess = os.FindProcess
personalSignalProcess = (*os.Process).Signal
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
personalLoadTokenData = authpkg.LoadTokenData
personalClientID = authpkg.ClientID
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
)
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
@@ -116,13 +143,10 @@ func newEventSchemaCommand() *cobra.Command {
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
_, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as != "user" {
return fmt.Errorf("event schema is only supported with --as user")
}
def, ok := personal.Lookup(args[0])
if !ok {
return fmt.Errorf("unknown personal event key %q", args[0])
@@ -181,7 +205,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -223,18 +247,18 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return consume.Run(ctx, cfg)
return personalConsumeRun(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
}
if err := personal.UpsertRunState(workDir, personal.RunState{
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
EventKey: eventKey,
RuleType: ruleType,
@@ -245,8 +269,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return fmt.Errorf("event consume --as user: save run state: %w", err)
}
cleanup := func() {
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
// Ownership-based cleanup (AI-subprocess contract, aligned with
// lark-cli): a subscription this run CREATED is unsubscribed on exit
@@ -279,24 +303,22 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
cfg.Stdin = c.InOrStdin()
}
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := consume.ValidateConfig(cfg); err != nil {
if err := personalValidateConsumeConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
@@ -319,13 +341,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Source: src,
}
bus.ApplyEnvTuning(&busCfg)
err = bus.Run(ctx, busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
err = consume.Run(ctx, cfg)
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
@@ -349,7 +371,7 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
if err != nil {
return nil, "", "", err
}
@@ -399,7 +421,7 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := client.CreateSubscription(ctx, req)
sub, err := personalCreateSubscription(client, ctx, req)
if err != nil {
return nil, "", "", err
}
@@ -412,17 +434,17 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
entry := personalFindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
var qs busctl.EntryStatus
if entry != nil {
qs = busctl.QueryEntry(*entry)
qs = personalQueryEntry(*entry)
} else {
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
WorkDir: workDir,
@@ -444,7 +466,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -547,7 +569,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
@@ -561,18 +583,18 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := client.DeleteSubscription(ctx, id); err != nil {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
}
}
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
remaining, err := personal.LoadRunStates(workDir)
remaining, err := personalLoadRunStates(workDir)
if err != nil {
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
}
@@ -582,7 +604,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
busState := "personal bus stopped"
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
@@ -604,7 +626,7 @@ func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
if !all {
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
}
states, err := personal.LoadRunStates(workDir)
states, err := personalLoadRunStates(workDir)
if err != nil {
return nil, err
}
@@ -629,7 +651,7 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
if ipcEndpoint == "" || len(targets) == 0 {
return nil
}
status, err := busctl.QueryStatus(ipcEndpoint)
status, err := personalQueryStatus(ipcEndpoint)
if err != nil {
return nil
}
@@ -644,11 +666,11 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
if _, ok := signalled[consumer.PID]; ok {
continue
}
proc, err := os.FindProcess(consumer.PID)
proc, err := personalFindProcess(consumer.PID)
if err != nil {
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
}
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
if err := personalSignalProcess(proc, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
}
signalled[consumer.PID] = struct{}{}
@@ -665,11 +687,11 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := authpkg.LoadTokenData(configDir)
tokenData, _ := personalLoadTokenData(configDir)
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
@@ -684,10 +706,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
userID = resolveRuntimeDefault(ctx, "$currentUserId")
}
if clientID == "" {
clientID = authpkg.ClientID()
clientID = personalClientID()
}
if clientID == "" {
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
if id, _, _, _, err := personalResolveAppCredentialsStrict(configDir); err == nil {
clientID = id
}
}
@@ -750,7 +772,7 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
clientID := opts.Identity.ClientID
clientSecret := ""
if mode == "custom" {
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
resolvedID, secret, _, _, err := personalResolveAppCredentialsStrict(opts.ConfigDir)
if err != nil {
return nil, err
}
@@ -852,10 +874,7 @@ func personalEventStreamSourceID(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
return v
}
return "open"
return strings.TrimSpace(edition.PersonalEventSourceID())
}
func personalEventMCPBaseURL(configDir string) string {
@@ -0,0 +1,370 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"os"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverage(t *testing.T) {
for _, args := range [][]string{
{"known", "--as", "app"},
{"not-a-real-event"},
} {
cmd := newEventSchemaCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("schema args %#v succeeded", args)
}
}
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
t.Fatal("empty subscription event key succeeded")
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventFromUser}, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
t.Fatal("private subscription event succeeded")
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventMention}, nil
}
if _, _, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || rule == "" {
t.Fatalf("default subscription rule = %q, %v", rule, err)
}
personalCreateSubscription = func(*personal.Client, context.Context, personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
return nil, wantErr
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("create subscription error = %v", err)
}
}
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldConsume := personalConsumeRun
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRun = oldConsume
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
})
wantErr := errors.New("consume")
cmd := newPersonalCoverageCommand()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("identity error = %v", err)
}
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{RoutesRaw: []string{"bad-route"}}}); err == nil {
t.Fatal("invalid route succeeded")
}
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
_ = cmd.Flags().Set("format", "table")
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{DryRun: true, FormatRaw: "bogus"}}); !errors.Is(err, wantErr) {
t.Fatalf("dry-run consume error = %v", err)
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return nil, "", "", wantErr
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("ensure subscription error = %v", err)
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{}, personal.EventMention, "at", nil
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); err == nil || !strings.Contains(err.Error(), "empty subscribe_id") {
t.Fatalf("empty subscription = %v", err)
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("state upsert error = %v", err)
}
deletes := 0
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { deletes++; return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, DebugRawEvents: true}); !errors.Is(err, wantErr) {
t.Fatalf("validate error = %v", err)
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
_ = cmd.Flags().Set("output", "file")
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("output conflict = %v", err)
}
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, wantErr
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
}
before := deletes
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
personalBusRun = func(context.Context, bus.Config) error { return wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) {
t.Fatalf("bus run error = %v", err)
}
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
t.Fatalf("background consume error = %v", err)
}
}
func TestCrossPlatformCoveragePersonalEventRemainingStatusStopAndInterruptCoverage(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldFindBus := personalFindBusByIdentity
oldQueryEntry := personalQueryEntry
oldList := personalListSubscriptions
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldLoad := personalLoadRunStates
oldStop := personalStopBus
oldQueryStatus := personalQueryStatus
oldFindProcess := personalFindProcess
oldSignal := personalSignalProcess
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalFindBusByIdentity = oldFindBus
personalQueryEntry = oldQueryEntry
personalListSubscriptions = oldList
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalLoadRunStates = oldLoad
personalStopBus = oldStop
personalQueryStatus = oldQueryStatus
personalFindProcess = oldFindProcess
personalSignalProcess = oldSignal
})
wantErr := errors.New("status-stop")
cmd := newPersonalCoverageCommand()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
t.Fatalf("status identity error = %v", err)
}
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("stop identity error = %v", err)
}
identity := personal.Identity{ClientID: "client", SourceID: "source"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
entry := &busctl.BusEntry{State: busctl.BusStateRunning}
personalFindBusByIdentity = func(string, string, dwsevent.SourceKind, string) *busctl.BusEntry { return entry }
personalQueryEntry = func(busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: *entry} }
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
return nil, wantErr
}
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
t.Fatalf("status list error = %v", err)
}
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
return nil, nil
}
if err := runPersonalEventStatus(cmd, personalStatusOptions{Status: "all", Format: "json"}); err != nil {
t.Fatalf("status entry JSON = %v", err)
}
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
if err := runPersonalEventStop(cmd, personalStopOptions{All: true}); !errors.Is(err, wantErr) {
t.Fatalf("stop targets error = %v", err)
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return wantErr }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("delete error = %v", err)
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return wantErr }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("remove error = %v", err)
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return nil, wantErr }
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("remaining state error = %v", err)
}
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) {
return &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{{SubscribeID: "sub", PID: 123}}}, nil
}
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
t.Fatalf("interrupt warning stop = %v", err)
}
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
t.Fatalf("remaining bus stop = %v", err)
}
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, nil }
personalStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
t.Fatalf("not running stop = %v", err)
}
personalStopBus = func(busctl.StopConfig) error { return wantErr }
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("bus stop error = %v", err)
}
personalLoadRunStates = func(string) ([]personal.RunState, error) {
return []personal.RunState{{}, {SubscribeID: "b"}, {SubscribeID: "a"}}, nil
}
if got, err := personalStopTargets("", "", true); err != nil || strings.Join(got, ",") != "a,b" {
t.Fatalf("stop target filtering = %#v, %v", got, err)
}
status := &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{
{SubscribeID: "other", PID: 1},
{SubscribeID: "sub", PID: 0},
{SubscribeID: "sub", PID: os.Getpid()},
{SubscribeID: "sub", PID: 123},
{SubscribeID: "sub", PID: 123},
}}
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return status, nil }
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
if err := interruptPersonalConsumers("ipc", []string{" sub ", ""}); !errors.Is(err, wantErr) {
t.Fatalf("find process error = %v", err)
}
proc := &os.Process{}
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
personalSignalProcess = func(*os.Process, os.Signal) error { return wantErr }
if err := interruptPersonalConsumers("ipc", []string{"sub"}); !errors.Is(err, wantErr) {
t.Fatalf("signal process error = %v", err)
}
personalSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
if err := interruptPersonalConsumers("ipc", []string{"sub"}); err != nil {
t.Fatalf("completed process signal = %v", err)
}
}
func TestCrossPlatformCoveragePersonalEventRemainingIdentityAndSourceCoverage(t *testing.T) {
oldAux := personalResolveAuxiliaryAccessToken
oldLoad := personalLoadTokenData
oldClientID := personalClientID
oldCredentials := personalResolveAppCredentialsStrict
oldEdition := edition.Get()
t.Cleanup(func() {
personalResolveAuxiliaryAccessToken = oldAux
personalLoadTokenData = oldLoad
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldCredentials
edition.Override(oldEdition)
})
wantErr := errors.New("identity")
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "", wantErr }
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); !errors.Is(err, wantErr) {
t.Fatalf("aux token error = %v", err)
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "access", nil }
personalLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
personalClientID = func() string { return "" }
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "resolved", "secret", "", "", nil
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return " corp ", true },
"$currentUserId": func(context.Context) (string, bool) { return " user ", true },
}
}})
if got, err := resolvePersonalEventIdentity(context.Background(), "", "source"); err != nil || got.ClientID != "resolved" || got.CorpID != "corp" {
t.Fatalf("resolved identity = %#v, %v", got, err)
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", "", "", wantErr
}
edition.Override(&edition.Hooks{})
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); err == nil {
t.Fatal("missing client ID succeeded")
}
if got := resolveRuntimeDefault(context.Background(), "missing"); got != "" {
t.Fatalf("missing runtime default = %q", got)
}
if _, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: "", Identity: personal.Identity{}, TicketMode: "custom"}); !errors.Is(err, wantErr) {
t.Fatalf("custom credential error = %v", err)
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "resolved", "secret", "", "", nil
}
if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{Identity: personal.Identity{AccessToken: "token", SourceID: "source"}, TicketMode: "custom"}); err != nil || src == nil {
t.Fatalf("custom resolved source = %#v, %v", src, err)
}
if got := personalEventStreamSourceID(""); got != "open" {
t.Fatalf("default stream source = %q", got)
}
if got := configuredMCPBaseURL(""); got != "" {
t.Fatalf("default missing configured MCP = %q", got)
}
}
func newPersonalCoverageCommand() *cobra.Command {
cmd := &cobra.Command{Use: "event"}
cmd.SetContext(context.Background())
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.Flags().String("format", "table", "")
cmd.Flags().String("output", "", "")
return cmd
}
var _ = time.Second
+16 -4
View File
@@ -23,6 +23,20 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
type accessTokenGetter interface {
GetAccessToken(context.Context) (string, error)
}
var (
markAccessTokenStale = authpkg.MarkAccessTokenStale
newRefreshProvider = func(configDir string) accessTokenGetter {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
return provider
}
)
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
// the new access_token. It is intended for callers that have observed a
// server-side rejection (HTTP 401 or business code such as
@@ -43,12 +57,10 @@ func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, err
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
if err := markAccessTokenStale(configDir); err != nil {
return "", fmt.Errorf("mark access token stale: %w", err)
}
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
provider := newRefreshProvider(configDir)
tok, err := provider.GetAccessToken(ctx)
if err != nil {
return "", err
+45 -28
View File
@@ -48,10 +48,26 @@ const (
PatAuthPollInterval = 5 * time.Second
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
patOrgPolicyDeniedCode = "PAT_ORG_POLICY_DENIED"
)
var openBrowserFunc = tryOpenBrowser
var (
patAuthorizationTimeout = PatAuthRetryTimeout
patAuthorizationPollInterval = PatAuthPollInterval
patLoadTokenData = authpkg.LoadTokenData
patWaitForAuthorization = WaitForPatAuthorization
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
patSaveAppConfig = authpkg.SaveAppConfig
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
patSaveTokenData = authpkg.SaveTokenData
patSleep = time.Sleep
patPollHTTPDo = (*http.Client).Do
patPollNewRequest = http.NewRequestWithContext
patBrowserOpenCommand = browserOpenCommand
)
type patSuppressBrowserOpenKeyType struct{}
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
@@ -234,12 +250,13 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
delete(data, "authUrl")
delete(data, "authorizationUrl")
}
data["openBrowser"] = openBrowser
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
if code, _ := payload["code"].(string); code == "PAT_ORG_POLICY_DENIED" {
data["openBrowser"] = false
} else {
data["openBrowser"] = openBrowser
}
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
return string(encoded)
}
@@ -256,9 +273,9 @@ func patAuthorizationURIFromData(data map[string]any) string {
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
timeout := PatAuthRetryTimeout
timeout := patAuthorizationTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(PatAuthPollInterval)
pollTicker := time.NewTicker(patAuthorizationPollInterval)
defer pollTicker.Stop()
start := time.Now()
@@ -286,7 +303,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
remaining := time.Until(deadline).Truncate(time.Second)
// Check if token is now valid
tokenData, err := authpkg.LoadTokenData(configDir)
tokenData, err := patLoadTokenData(configDir)
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
@@ -323,7 +340,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
PrintPatAuthError(output, scopeErr)
// Wait for user to complete authorization
authorized := WaitForPatAuthorization(ctx, configDir, output)
authorized := patWaitForAuthorization(ctx, configDir, output)
if !authorized {
return executor.Result{}, apperrors.NewAuth(
"等待用户授权超时",
@@ -500,6 +517,15 @@ func handlePatAuthCheck(
if patData.Data.URI == "" {
patData.Data.URI = patData.Data.AuthorizationURL
}
// Organization-policy denial is terminal until an administrator changes
// the policy. Return it before reading browser policy, mutating process-wide
// credentials, opening a browser, polling, or retrying the invocation even
// when a lenient backend also supplies active-flow fields.
if patData.Code == patOrgPolicyDeniedCode {
return executor.Result{}, &apperrors.PATError{
RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, false),
}
}
slog.Debug("PAT auth check",
"clientId", patData.Data.ClientID,
@@ -579,7 +605,7 @@ func handlePatAuthCheck(
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
defer cancel()
status, authCode, err := pollPatDeviceFlowWithInterval(
status, authCode, err := patPollDeviceFlowWithInterval(
pollCtx, patData.Data.FlowID, configDir, output,
resolvePATPollInterval(patData.Data.PollIntervalSecs),
)
@@ -594,7 +620,7 @@ func handlePatAuthCheck(
fmt.Fprintln(output)
if appCfg != nil {
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
if err := patSaveAppConfig(configDir, appCfg); err != nil {
slog.Warn("failed to persist approved app config from PAT", "error", err)
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
}
@@ -603,12 +629,12 @@ func handlePatAuthCheck(
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
if authCode != "" {
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
tokenData, exchErr := patExchangeCodeForToken(ctx, configDir, authCode)
if exchErr != nil {
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
} else {
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
if err := patSaveTokenData(configDir, tokenData); err != nil {
slog.Warn("PAT retry: failed to save new token", "error", err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
} else {
@@ -633,7 +659,7 @@ func handlePatAuthCheck(
// Workaround: brief delay to let server-side authorization state propagate
// before retrying. Without this the retry may use stale credentials.
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
time.Sleep(1 * time.Second)
patSleep(1 * time.Second)
// Retry the original invocation with pat-retrying flag to prevent recursion.
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
@@ -705,10 +731,7 @@ func enrichPATErrorForHostControl(raw string) string {
apperrors.ApplyHostMutations(payload)
// stderr JSON MUST be single-line.
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
return string(encoded)
}
@@ -739,10 +762,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
"data": data,
}
// stderr JSON MUST be single-line.
b, err := jsonutil.Marshal(payload)
if err != nil {
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
}
b, _ := jsonutil.Marshal(payload)
return string(b)
}
@@ -803,7 +823,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
pollCount++
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
req, err := patPollNewRequest(ctx, http.MethodGet, pollURL, nil)
if err != nil {
slog.Debug("PAT poll: failed to create request", "error", err)
continue
@@ -811,7 +831,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
if accessToken != "" {
req.Header.Set("x-user-access-token", accessToken)
}
resp, err := noRedirectClient.Do(req)
resp, err := patPollHTTPDo(noRedirectClient, req)
if err != nil {
slog.Debug("PAT poll: request failed", "error", err)
continue // transient network error, keep polling
@@ -858,9 +878,6 @@ func resolvePATPollInterval(seconds int) time.Duration {
return patPollInterval
}
interval := time.Duration(seconds) * time.Second
if interval < time.Second {
return time.Second
}
if interval > patMaxPollInterval {
return patMaxPollInterval
}
@@ -882,7 +899,7 @@ func browserOpenCommand(goos, rawURL string) *exec.Cmd {
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
func tryOpenBrowser(rawURL string) error {
cmd := browserOpenCommand(runtime.GOOS, rawURL)
cmd := patBrowserOpenCommand(runtime.GOOS, rawURL)
if cmd == nil {
return nil
}
@@ -0,0 +1,281 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os/exec"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T) {
typed := apperrors.NewAPI("ordinary", apperrors.WithHint("insufficient_scope"))
if !isPatScopeError(typed) {
t.Fatal("typed insufficient_scope was not recognized")
}
typed = apperrors.NewAPI("ordinary", apperrors.WithReason("reason"))
if got := extractPatScopeError(typed); !strings.Contains(got.Message, "reason") {
t.Fatalf("typed scope message = %q", got.Message)
}
var out bytes.Buffer
PrintPatAuthError(&out, &PatScopeError{Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login"})
if !strings.Contains(out.String(), "dws auth login") {
t.Fatalf("PAT output = %q", out.String())
}
if wantsStructuredPATOutputFromRunner(runnerCoverageFallback{}) {
t.Fatal("non-runtime runner requested structured PAT output")
}
if got := enrichPATErrorWithOpenBrowser("", true); got != "" {
t.Fatalf("empty enriched PAT = %q", got)
}
if got := enrichPATErrorWithOpenBrowser("not-json", true); got != "not-json" {
t.Fatalf("malformed enriched PAT = %q", got)
}
if got := enrichPATErrorWithOpenBrowser(`{"code":"x"}`, true); !strings.Contains(got, "openBrowser") {
t.Fatalf("missing data enrichment = %q", got)
}
if got := patAuthorizationURIFromData(map[string]any{"authorizationUrl": " final "}); got != "final" {
t.Fatalf("authorization URI = %q", got)
}
if err := openPATAuthorizationURI(""); err != nil {
t.Fatal(err)
}
oldTimeout := patAuthorizationTimeout
oldInterval := patAuthorizationPollInterval
oldLoad := patLoadTokenData
t.Cleanup(func() {
patAuthorizationTimeout = oldTimeout
patAuthorizationPollInterval = oldInterval
patLoadTokenData = oldLoad
})
patAuthorizationTimeout = 50 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
out.Reset()
if !WaitForPatAuthorization(context.Background(), "", &out) {
t.Fatal("valid token did not authorize")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
out.Reset()
if WaitForPatAuthorization(ctx, "", &out) {
t.Fatal("cancelled authorization succeeded")
}
patAuthorizationTimeout = time.Millisecond
patAuthorizationPollInterval = time.Hour
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) {
t.Fatal("timed out authorization succeeded")
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling output = %q", out.String())
}
}
func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.T) {
oldWait := patWaitForAuthorization
oldPoll := patPollDeviceFlowWithInterval
oldSaveApp := patSaveAppConfig
oldExchange := patExchangeCodeForToken
oldSaveToken := patSaveTokenData
oldSleep := patSleep
oldOpen := openBrowserFunc
t.Cleanup(func() {
patWaitForAuthorization = oldWait
patPollDeviceFlowWithInterval = oldPoll
patSaveAppConfig = oldSaveApp
patExchangeCodeForToken = oldExchange
patSaveTokenData = oldSaveToken
patSleep = oldSleep
openBrowserFunc = oldOpen
})
t.Setenv(authpkg.AgentCodeEnv, "")
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
t.Fatal("PAT retry timeout succeeded")
}
wantErr := errors.New("runner failed")
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
t.Fatalf("authorized retry = %v", err)
}
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if err := runDirectPATAuthCheck(context.Background(), nil, patErr, nil, io.Discard); !errors.Is(err, patErr) {
t.Fatalf("nil direct retry = %v", err)
}
if err := runDirectPATAuthCheckWithMode(context.Background(), nil, patErr, nil, io.Discard, true); !errors.Is(err, patErr) {
t.Fatalf("nil retry mode = %v", err)
}
badRetry := errors.New("retry callback")
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return authpkg.StatusApproved, "", nil
}
patSleep = func(time.Duration) {}
if err := runDirectPATAuthCheck(context.Background(), nil, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, func(context.Context) error { return badRetry }, io.Discard); !errors.Is(err, badRetry) {
t.Fatalf("direct retry callback = %v", err)
}
malformedPAT := &apperrors.PATError{RawJSON: `{`}
if _, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, executor.Invocation{}, malformedPAT, t.TempDir(), io.Discard); !errors.Is(err, malformedPAT) {
t.Fatalf("malformed PAT handler = %v", err)
}
openBrowserFunc = func(string) error { return nil }
for _, raw := range []string{
`{"code":"x","data":{"flowId":"f","authUrl":"https://auth.test","desc":"authorize"}}`,
`{"code":"x","data":{"flowId":"f","authorizationUrl":"https://auth2.test"}}`,
} {
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return "", "", wantErr
}
ctx := context.WithValue(context.Background(), patSuppressBrowserOpenKey, true)
if _, err := handlePatAuthCheck(ctx, &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: raw}, t.TempDir(), io.Discard); err == nil {
t.Fatal("poll failure returned nil")
}
}
statuses := []string{authpkg.StatusRejected, authpkg.StatusExpired, authpkg.StatusCancelled, "UNKNOWN"}
for _, status := range statuses {
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return status, "", nil
}
if _, err := handlePatAuthCheck(context.WithValue(context.Background(), patSuppressBrowserOpenKey, true), &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err == nil {
t.Fatalf("status %s returned nil", status)
}
}
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return authpkg.StatusApproved, "code", nil
}
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return wantErr }
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { return nil, wantErr }
skip := executor.Invocation{Params: map[string]any{"retryAfterApproval": false}}
if got, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, skip, &apperrors.PATError{RawJSON: patRaw("f", "client", "secret")}, t.TempDir(), io.Discard); err != nil || !got.Invocation.Implemented {
t.Fatalf("approved skip = %#v, %v", got, err)
}
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token"}, nil
}
patSaveTokenData = func(string, *authpkg.TokenData) error { return wantErr }
r := &runtimeRunner{fallback: runnerCoverageFallback{result: executor.Result{Response: map[string]any{"ok": true}}}}
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "client", "")}, t.TempDir(), io.Discard); err != nil {
t.Fatalf("approved retry with save failure = %v", err)
}
patSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err != nil {
t.Fatalf("approved retry = %v", err)
}
for _, inv := range []executor.Invocation{{}, {Params: map[string]any{}}, {Params: map[string]any{"retryAfterApproval": "no"}}, {Params: map[string]any{"retryAfterApproval": true}}} {
if shouldSkipPATRetryAfterApproval(inv) {
t.Fatalf("unexpected skip for %#v", inv.Params)
}
}
if got := enrichPATErrorForHostControl(""); got != "" {
t.Fatalf("empty host PAT = %q", got)
}
if got := enrichPATErrorForHostControl("bad"); got != "bad" {
t.Fatalf("bad host PAT = %q", got)
}
if got := enrichPATErrorForHostControl(`{"value":1}`); !strings.Contains(got, "value") {
t.Fatalf("generic host PAT = %q", got)
}
if _, err := marshalSingleLineJSONNoHTMLEscape(map[string]any{"bad": func() {}}); err == nil {
t.Fatal("unsupported JSON value succeeded")
}
}
func patRaw(flowID, clientID, secret string) string {
return `{"code":"x","data":{"desc":"authorize","flowId":"` + flowID + `","uri":"https://auth.test","clientId":"` + clientID + `","clientSecret":"` + secret + `"}}`
}
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
oldDo := patPollHTTPDo
oldRequest := patPollNewRequest
oldLoad := patLoadTokenData
oldBrowser := patBrowserOpenCommand
t.Cleanup(func() {
patPollHTTPDo = oldDo
patPollNewRequest = oldRequest
patLoadTokenData = oldLoad
patBrowserOpenCommand = oldBrowser
})
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
cancelled, cancelNow := context.WithCancel(context.Background())
cancelNow()
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("zero-interval cancelled poll = %q, %v", status, err)
}
expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancelExpired()
if status, _, err := pollPatDeviceFlowWithInterval(expired, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusExpired {
t.Fatalf("expired-context poll = %q, %v", status, err)
}
ctx, cancel := context.WithCancel(context.Background())
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
cancel()
return nil, errors.New("network")
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("network poll = %q, %v", status, err)
}
ctx, cancel = context.WithCancel(context.Background())
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
cancel()
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{"))}, nil
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("malformed poll = %q, %v", status, err)
}
ctx, cancel = context.WithCancel(context.Background())
patPollNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) {
cancel()
return nil, errors.New("request")
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("invalid request poll = %q, %v", status, err)
}
patPollNewRequest = oldRequest
var out bytes.Buffer
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
printPATPollDebugResponse(&out, 500, nil)
if !strings.Contains(out.String(), "empty body") {
t.Fatalf("empty debug response = %q", out.String())
}
for _, goos := range []string{"darwin", "linux", "windows", "plan9"} {
_ = browserOpenCommand(goos, "https://example.test")
}
patBrowserOpenCommand = func(string, string) *exec.Cmd { return nil }
if err := tryOpenBrowser("https://example.test"); err != nil {
t.Fatal(err)
}
patBrowserOpenCommand = func(string, string) *exec.Cmd { return exec.Command("definitely-not-a-real-dws-command") }
if err := tryOpenBrowser("https://example.test"); err == nil {
t.Fatal("missing browser command started")
}
}
+89
View File
@@ -650,6 +650,95 @@ func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *
}
}
func TestCrossPlatformCoverageHandlePatAuthCheckOrgPolicyDenied(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
originalClientID := authpkg.ClientID()
originalClientSecret := authpkg.ClientSecret()
t.Cleanup(func() {
authpkg.SetClientID(originalClientID)
authpkg.SetClientSecret(originalClientSecret)
})
originalOpenBrowser := openBrowserFunc
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
for _, test := range []struct {
name string
format string
}{
{name: "structured", format: "json"},
{name: "human"},
} {
t.Run(test.name, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
authpkg.SetClientID("existing-client-id")
authpkg.SetClientSecret("existing-client-secret")
opened := false
openBrowserFunc = func(string) error {
opened = true
return nil
}
retried := false
runner := &runtimeRunner{
globalFlags: &GlobalFlags{Format: test.format},
fallback: &mockRunner{runFunc: func(context.Context, executor.Invocation) (executor.Result, error) {
retried = true
return executor.Result{}, nil
}},
}
raw := `{"success":false,"code":"PAT_ORG_POLICY_DENIED","data":{"hint":"组织策略已禁止当前工具所需的开源数据权限","scope":"contact.user.read","flowId":"terminal-flow","uri":"https://example.com/pat","clientId":"denied-client-id","clientSecret":"denied-client-secret","openBrowser":true}}`
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
var out bytes.Buffer
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
CanonicalPath: "contact.get_current_user_profile",
}, &apperrors.PATError{RawJSON: raw}, configDir, &out)
if err == nil {
t.Fatal("expected PATError")
}
if got := strings.TrimSpace(out.String()); got != "" {
t.Fatalf("terminal denial produced human authorization or polling output %q", got)
}
if opened {
t.Fatal("terminal denial opened a browser")
}
if retried {
t.Fatal("terminal denial retried the invocation")
}
if got := authpkg.ClientID(); got != "existing-client-id" {
t.Fatalf("client ID = %q, want existing process credential preserved", got)
}
if got := authpkg.ClientSecret(); got != "existing-client-secret" {
t.Fatalf("client secret = %q, want existing process credential preserved", got)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, ok := data["openBrowser"].(bool); !ok || got {
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
}
if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") {
t.Fatalf("data.hint = %q, want org policy guidance", got)
}
})
}
}
func TestHandlePatAuthCheck_Approved(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
+24 -12
View File
@@ -26,6 +26,18 @@ import (
"github.com/spf13/cobra"
)
var (
pluginInstallFromGit = (*plugin.Loader).InstallFromGit
pluginStat = os.Stat
pluginMkdirAll = os.MkdirAll
pluginWriteFile = os.WriteFile
pluginAbs = filepath.Abs
pluginRegisterDev = (*plugin.Loader).RegisterDevPlugin
pluginListInstalled = (*plugin.Loader).ListInstalled
pluginParseManifest = plugin.ParseManifest
pluginBuild = plugin.BuildPlugin
)
func newPluginCommand() *cobra.Command {
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
@@ -98,7 +110,7 @@ func newPluginInstallCommand() *cobra.Command {
loader := plugin.NewLoader(RawVersion())
if gitURL != "" {
p, err := loader.InstallFromGit(gitURL)
p, err := pluginInstallFromGit(loader, gitURL)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
}
@@ -247,7 +259,7 @@ func newPluginCreateCommand() *cobra.Command {
}
dir := filepath.Join(".", name)
if _, err := os.Stat(dir); err == nil {
if _, err := pluginStat(dir); err == nil {
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
}
@@ -258,7 +270,7 @@ func newPluginCreateCommand() *cobra.Command {
filepath.Join(dir, "hooks"),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0o755); err != nil {
if err := pluginMkdirAll(d, 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
}
}
@@ -286,7 +298,7 @@ func newPluginCreateCommand() *cobra.Command {
}
`, name, desc, pluginType, RawVersion(), name)
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
if err := pluginWriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
}
@@ -317,7 +329,7 @@ Use this skill when the user mentions:
- Conversion rules
`, name, desc, RawVersion(), name, name)
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
if err := pluginWriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
}
@@ -326,7 +338,7 @@ Use this skill when the user mentions:
"hooks": []
}
`
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
if err := pluginWriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
}
@@ -377,7 +389,7 @@ to unregister.`,
// Register dev plugin
dir := args[0]
absDir, err := filepath.Abs(dir)
absDir, err := pluginAbs(dir)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
}
@@ -391,7 +403,7 @@ to unregister.`,
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
}
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
if err := pluginRegisterDev(loader, m.Name, absDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
}
@@ -582,10 +594,10 @@ func newPluginConfigUnsetCommand() *cobra.Command {
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
plugins := loader.ListInstalled()
plugins := pluginListInstalled(loader)
for _, p := range plugins {
if p.Name == pluginName {
m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json"))
m, err := pluginParseManifest(filepath.Join(p.Path, "plugin.json"))
if err != nil {
return nil
}
@@ -626,7 +638,7 @@ The build configuration is read from the "build" field in plugin.json:
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
dir := args[0]
absDir, err := filepath.Abs(dir)
absDir, err := pluginAbs(dir)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
}
@@ -646,7 +658,7 @@ The build configuration is read from the "build" field in plugin.json:
" }", m.Name))
}
if err := plugin.BuildPlugin(absDir); err != nil {
if err := pluginBuild(absDir); err != nil {
return apperrors.NewInternal(err.Error())
}
@@ -0,0 +1,181 @@
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/spf13/cobra"
)
func pluginCoverageRun(cmd *cobra.Command, args ...string) (string, error) {
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoveragePluginCommandRemainingCoverage(t *testing.T) {
oldGit := pluginInstallFromGit
oldStat := pluginStat
oldMkdir := pluginMkdirAll
oldWrite := pluginWriteFile
oldAbs := pluginAbs
oldRegister := pluginRegisterDev
oldBuild := pluginBuild
oldList, oldParse := pluginListInstalled, pluginParseManifest
t.Cleanup(func() {
pluginInstallFromGit = oldGit
pluginStat = oldStat
pluginMkdirAll = oldMkdir
pluginWriteFile = oldWrite
pluginAbs = oldAbs
pluginRegisterDev = oldRegister
pluginBuild = oldBuild
pluginListInstalled, pluginParseManifest = oldList, oldParse
})
home := t.TempDir()
work := t.TempDir()
t.Setenv("HOME", home)
oldWD, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
fail := errors.New("failure")
pluginListInstalled = func(*plugin.Loader) []plugin.PluginInfo {
return []plugin.PluginInfo{{Name: "broken", Path: "missing"}}
}
pluginParseManifest = func(string) (*plugin.Manifest, error) { return nil, fail }
if got := loadDeclaredUserConfig(plugin.NewLoader(RawVersion()), "broken"); got != nil {
t.Fatalf("broken declared config = %#v", got)
}
pluginListInstalled, pluginParseManifest = oldList, oldParse
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) { return nil, fail }
if _, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err == nil {
t.Fatal("git install failure should propagate")
}
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) {
return &plugin.Plugin{Manifest: plugin.Manifest{Name: "git-plugin", Version: "1.0.0"}}, nil
}
if out, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err != nil || !strings.Contains(out, "git-plugin") {
t.Fatalf("git install = %q, %v", out, err)
}
if _, err := pluginCoverageRun(newPluginDisableCommand(), "missing"); err == nil {
t.Fatal("disable missing plugin should fail")
}
invalidDir := filepath.Join(work, "invalid")
if err := os.MkdirAll(invalidDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(invalidDir, "plugin.json"), []byte(`{"name":"ok-name","version":"1.0.0","type":"invalid"}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := pluginCoverageRun(newPluginValidateCommand(), invalidDir); err == nil {
t.Fatal("invalid manifest validation should fail")
}
pluginStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
pluginMkdirAll = func(string, os.FileMode) error { return fail }
if _, err := pluginCoverageRun(newPluginCreateCommand(), "mkdir-plugin"); err == nil {
t.Fatal("scaffold mkdir failure should propagate")
}
pluginMkdirAll = oldMkdir
for _, target := range []string{"plugin.json", "SKILL.md", "hooks.json"} {
name := "write-" + strings.ToLower(strings.TrimSuffix(target, filepath.Ext(target)))
pluginWriteFile = func(path string, data []byte, mode os.FileMode) error {
if strings.HasSuffix(path, target) {
return fail
}
return oldWrite(path, data, mode)
}
if _, err := pluginCoverageRun(newPluginCreateCommand(), name); err == nil {
t.Fatalf("scaffold %s failure should propagate", target)
}
}
pluginWriteFile = oldWrite
pluginAbs = func(string) (string, error) { return "", fail }
if _, err := pluginCoverageRun(newPluginDevCommand(), "dir"); err == nil {
t.Fatal("dev absolute-path failure should propagate")
}
pluginAbs = oldAbs
if _, err := pluginCoverageRun(newPluginDevCommand(), invalidDir); err == nil {
t.Fatal("dev manifest validation should fail")
}
validDir := filepath.Join(work, "valid")
if err := os.MkdirAll(validDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name":"valid-plugin","version":"1.0.0","type":"user",
"userConfig":{
"API_KEY":{"description":"secret key","sensitive":true},
"PLAIN":{"description":"plain value","default":"default"},
"UNSET":{"description":"required value"}
},
"build":{"command":"true","output":"bin/server"}
}`
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
pluginRegisterDev = func(*plugin.Loader, string, string) error { return fail }
if _, err := pluginCoverageRun(newPluginDevCommand(), validDir); err == nil {
t.Fatal("dev registration failure should propagate")
}
loader := plugin.NewLoader(RawVersion())
installed, err := loader.InstallFromDir(validDir)
if err != nil {
// The declared build output is intentionally absent; install a no-build copy.
noBuild := strings.Replace(manifest, ",\n \"build\":{\"command\":\"true\",\"output\":\"bin/server\"}", "", 1)
if writeErr := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(noBuild), 0o600); writeErr != nil {
t.Fatal(writeErr)
}
installed, err = loader.InstallFromDir(validDir)
}
if err != nil {
t.Fatal(err)
}
loader.SetPluginConfig("valid-plugin", "API_KEY", "abcdefghijk")
loader.SetPluginConfig("valid-plugin", "PLAIN", "value")
for _, args := range [][]string{{"valid-plugin"}, {"valid-plugin", "--json"}} {
out, runErr := pluginCoverageRun(newPluginConfigListCommand(), args...)
if runErr != nil || !strings.Contains(out, "UNSET") || !strings.Contains(out, "abcd") {
t.Fatalf("config list %#v = %q, %v", args, out, runErr)
}
}
if err := os.WriteFile(filepath.Join(installed.Root, "plugin.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if got := loadDeclaredUserConfig(loader, "valid-plugin"); got != nil {
t.Fatalf("corrupt declared config = %#v", got)
}
pluginAbs = func(string) (string, error) { return "", fail }
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
t.Fatal("build absolute-path failure should propagate")
}
pluginAbs = oldAbs
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
pluginBuild = func(string) error { return fail }
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
t.Fatal("plugin build failure should propagate")
}
}
+15 -9
View File
@@ -66,10 +66,10 @@ func newProfileListCommand() *cobra.Command {
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
if err := profileEnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
cfg, err := profileLoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
@@ -139,6 +139,12 @@ func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
var (
profileSwitchSelector = selectProfileSwitchProfile
profileSwitchInteractiveTerminal = isInteractiveTerminal
profileSwitchTUIRunner = runProfileSwitchTUI
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
profileLoadProfiles = authpkg.LoadProfiles
profileUsePrevious = authpkg.UsePreviousProfile
profileSetCurrent = authpkg.SetCurrentProfile
profileRunTeaProgram = (*tea.Program).Run
)
const (
@@ -218,9 +224,9 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
err error
)
if strings.TrimSpace(selector) == "-" {
profile, err = authpkg.UsePreviousProfile(configDir)
profile, err = profileUsePrevious(configDir)
} else {
profile, err = authpkg.SetCurrentProfile(configDir, selector)
profile, err = profileSetCurrent(configDir, selector)
}
if err != nil {
return apperrors.NewValidation(err.Error())
@@ -229,7 +235,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
clearCompatCache()
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
cfg, loadErr := authpkg.LoadProfiles(configDir)
cfg, loadErr := profileLoadProfiles(configDir)
if loadErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
}
@@ -243,10 +249,10 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
}
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
if err := profileEnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
cfg, err := profileLoadProfiles(configDir)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
@@ -260,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
if choice == "" {
choice = cfg.Profiles[0].CorpID
}
return runProfileSwitchTUI(cmd, cfg, choice)
return profileSwitchTUIRunner(cmd, cfg, choice)
}
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
@@ -272,7 +278,7 @@ func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, select
tea.WithOutput(cmd.ErrOrStderr()),
tea.WithContext(cmd.Context()),
)
finalModel, err := program.Run()
finalModel, err := profileRunTeaProgram(program)
if err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return "", apperrors.NewValidation("组织选择中止: user aborted")
+4
View File
@@ -110,6 +110,7 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
}
CloseFileLogger()
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use output should include organization name:\n%s", out.String())
}
@@ -136,6 +137,7 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
}
CloseFileLogger()
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
}
@@ -202,6 +204,7 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
}
CloseFileLogger()
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
@@ -218,6 +221,7 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
}
CloseFileLogger()
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
+165
View File
@@ -0,0 +1,165 @@
package app
import (
"context"
"errors"
"io"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageProfileRemainingCoverage(t *testing.T) {
oldMigrate := profileEnsureProfilesMigration
oldLoad := profileLoadProfiles
oldPrevious := profileUsePrevious
oldCurrent := profileSetCurrent
oldInteractive := profileSwitchInteractiveTerminal
oldTUI := profileSwitchTUIRunner
oldProgram := profileRunTeaProgram
t.Cleanup(func() {
profileEnsureProfilesMigration = oldMigrate
profileLoadProfiles = oldLoad
profileUsePrevious = oldPrevious
profileSetCurrent = oldCurrent
profileSwitchInteractiveTerminal = oldInteractive
profileSwitchTUIRunner = oldTUI
profileRunTeaProgram = oldProgram
})
fail := errors.New("failure")
list := newProfileListCommand()
_, _, _ = authCoverageRoot(list, "table", false)
profileEnsureProfilesMigration = func(string) error { return fail }
if err := list.RunE(list, nil); err == nil {
t.Fatal("profile-list migration should fail")
}
profileEnsureProfilesMigration = func(string) error { return nil }
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
if err := list.RunE(list, nil); err == nil {
t.Fatal("profile-list load should fail")
}
selectorCmd := &cobra.Command{}
addProfileSwitchSelectorFlags(selectorCmd)
_ = selectorCmd.Flags().Set("name", " ")
if _, err := profileSwitchSelectorFromCommand(selectorCmd, nil); err == nil {
t.Fatal("blank profile selector should fail")
}
profileSetCurrent = func(string, string) (*authpkg.Profile, error) {
return &authpkg.Profile{CorpID: "ding", CorpName: "Corp"}, nil
}
profileUsePrevious = func(string) (*authpkg.Profile, error) { return nil, fail }
cmd := &cobra.Command{}
_, _, _ = authCoverageRoot(cmd, "table", false)
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err == nil {
t.Fatal("previous-profile error should fail")
}
profileUsePrevious = func(string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "previous"}, nil }
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err != nil {
t.Fatal(err)
}
jsonCmd := &cobra.Command{}
_, _, _ = authCoverageRoot(jsonCmd, "json", false)
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
t.Fatal("JSON profile load should fail")
}
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{CurrentProfile: "ding"}, nil
}
jsonCmd.SetOut(&appFailWriter{err: fail})
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
t.Fatal("JSON profile write should fail")
}
profileSwitchInteractiveTerminal = func() bool { return true }
profileEnsureProfilesMigration = func(string) error { return fail }
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
t.Fatal("selector migration should fail")
}
profileEnsureProfilesMigration = func(string) error { return nil }
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
t.Fatal("selector load should fail")
}
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
t.Fatal("empty selector profiles should fail")
}
choices := []string{}
profileSwitchTUIRunner = func(_ *cobra.Command, _ *authpkg.ProfilesConfig, choice string) (string, error) {
choices = append(choices, choice)
return choice, nil
}
for _, cfg := range []*authpkg.ProfilesConfig{
{CurrentProfile: "current", PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
{PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
{Profiles: []authpkg.Profile{{CorpID: "first"}}},
} {
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err != nil {
t.Fatal(err)
}
}
if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" {
t.Fatalf("profile choices = %#v", choices)
}
tuiCmd := &cobra.Command{}
tuiCmd.SetContext(context.Background())
tuiCmd.SetIn(io.NopCloser(&emptyReader{}))
tuiCmd.SetErr(io.Discard)
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding"}}}
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, tea.ErrInterrupted }
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
t.Fatal("interrupted TUI should fail")
}
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, fail }
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
t.Fatal("failed TUI should fail")
}
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return structModel{}, nil }
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
t.Fatal("wrong TUI model should fail")
}
for _, model := range []profileSwitchTUIModel{{aborted: true}, {submitted: false}} {
model := model
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return model, nil }
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
t.Fatal("aborted TUI should fail")
}
}
final := newProfileSwitchTUIModel(cfg, "ding")
final.submitted = true
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return final, nil }
if got, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err != nil || got != "ding" {
t.Fatalf("submitted TUI = %q, %v", got, err)
}
profiles := make([]authpkg.Profile, 8)
model := profileSwitchTUIModel{profiles: profiles, selected: 7, offset: 99}
model.ensureSelectedVisible()
if model.offset != 3 {
t.Fatalf("clamped offset = %d", model.offset)
}
model.offset = -2
model.selected = 0
model.ensureSelectedVisible()
if model.offset != 0 {
t.Fatalf("negative offset = %d", model.offset)
}
}
type emptyReader struct{}
func (*emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
type structModel struct{}
func (structModel) Init() tea.Cmd { return nil }
func (structModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return structModel{}, nil }
func (structModel) View() string { return "" }
+7 -2
View File
@@ -18,6 +18,11 @@ import (
"github.com/spf13/cobra"
)
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
@@ -60,7 +65,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := store.SavePlan(last.EventID, plan); err != nil {
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
@@ -90,7 +95,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil {
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
@@ -0,0 +1,153 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(context.Background(), nil, nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
fail := errors.New("catalog")
SetDynamicServers(nil)
runtime := &recoveryRuntime{
loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail),
transport: transport.NewClient(nil),
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package app
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventStopPreviewConfirmationAndStdinCoverage(t *testing.T) {
originalNormalize := eventNormalizeAs
t.Cleanup(func() { eventNormalizeAs = originalNormalize })
eventNormalizeAs = func(string) (string, error) { return "app", nil }
newStopRoot := func(dryRun, yes bool) (*cobra.Command, *bytes.Buffer) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", dryRun, "")
root.PersistentFlags().Bool("yes", yes, "")
root.AddCommand(newEventStopCommand())
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
return root, &output
}
root, output := newStopRoot(true, false)
root.SetArgs([]string{"stop"})
if err := root.Execute(); err != nil || !strings.Contains(output.String(), "dry_run") {
t.Fatalf("event stop dry-run = %v, %q", err, output.String())
}
root, _ = newStopRoot(false, false)
root.SetArgs([]string{"stop"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("event stop confirmation error = %v", err)
}
originalStdin := os.Stdin
t.Cleanup(func() { os.Stdin = originalStdin })
closed, err := os.CreateTemp(t.TempDir(), "closed")
if err != nil {
t.Fatal(err)
}
if err := closed.Close(); err != nil {
t.Fatal(err)
}
os.Stdin = closed
if shouldWatchStdinEOF(0, 0) {
t.Fatal("shouldWatchStdinEOF(closed stdin) = true")
}
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer read.Close()
defer write.Close()
os.Stdin = read
if !shouldWatchStdinEOF(0, 0) {
t.Fatal("shouldWatchStdinEOF(pipe) = false")
}
var cfg consume.Config
applyEventConsumeStdin(nil, 0, 0, strings.NewReader("ignored"))
applyEventConsumeStdin(&cfg, 1, 0, strings.NewReader("bounded"))
if cfg.Stdin != nil {
t.Fatal("bounded consume received stdin watcher")
}
wantReader := strings.NewReader("pipe")
applyEventConsumeStdin(&cfg, 0, 0, wantReader)
if cfg.Stdin != wantReader {
t.Fatal("unbounded pipe consume did not receive stdin")
}
}
func TestCrossPlatformCoverageRunnerPluginStdioAndDryRunErrorCoverage(t *testing.T) {
if _, err := (*runtimeRunner)(nil).Run(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("nil runtime runner error = nil")
}
t.Setenv(authpkg.AgentCodeEnv, "codex")
t.Setenv("HOME", t.TempDir())
t.Setenv(envDingtalkAgent, "agent")
t.Setenv(envDingtalkTraceID, "trace")
t.Setenv(envDingtalkMessageID, "message")
headers := resolveIdentityHeaders()
for _, key := range []string{"x-dws-agent-instance-id", "x-dingtalk-agent", "x-dingtalk-trace-id", "x-dingtalk-message-id"} {
if headers[key] == "" {
t.Fatalf("identity header %q missing: %#v", key, headers)
}
}
stdioMu.Lock()
previous := stdioClients
stdioClients = map[string]*transport.StdioClient{"plugin/server": transport.NewStdioClient("missing", nil, nil)}
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = previous
stdioMu.Unlock()
})
if _, ok := LookupStdioClient("server"); !ok {
t.Fatal("LookupStdioClient(server suffix) failed")
}
if StopStdioClient("missing") {
t.Fatal("StopStdioClient(missing) = true")
}
registerPluginHTTPServer(mcptypes.ServerDescriptor{Key: "coverage", Endpoint: "https://example.test", AuthHeaders: map[string]string{"Authorization": "Bearer token"}})
originalDryRun := toolCallerDryRun
t.Cleanup(func() { toolCallerDryRun = originalDryRun })
toolCallerDryRun = func(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("dry-run")
}
adapter := newToolCallerAdapter(nil, &GlobalFlags{DryRun: true})
if _, err := adapter.CallTool(context.Background(), "product", "tool", nil); err == nil {
t.Fatal("tool caller dry-run error = nil")
}
}
+68 -30
View File
@@ -24,6 +24,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -50,6 +51,30 @@ type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
rootCreateFile = os.Create
rootCloseFile = (*os.File).Close
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
rootPluginLoadUser = (*plugin.Loader).LoadUser
rootPluginLoadDev = (*plugin.Loader).LoadDev
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
rootPluginStdioClients = (*plugin.Plugin).StdioClients
rootRegisterPluginHTTPServer = registerPluginHTTPServer
rootRegisterStdioManifest = registerStdioServerFromManifest
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
)
// Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) {
defer func() {
@@ -59,13 +84,13 @@ func Execute() (exitCode int) {
}
}()
restoreArgs := normalizeProcessProfileArgs()
restoreArgs := rootNormalizeProcessProfileArgs()
defer restoreArgs()
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
CloseAuditSink() // Drain async audit forwards on all exit paths,
rootStopAllStdioClients() // Ensure child processes are terminated on exit
CloseAuditSink() // Drain async audit forwards on all exit paths,
// including command errors where Cobra skips PersistentPostRunE.
timing.PrintIfEnabled()
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
@@ -78,17 +103,17 @@ func Execute() (exitCode int) {
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
recovery.ResetRuntimeState()
rootResetRecoveryState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
rootRunPreParse(root, engine)
executed, err := root.ExecuteC()
executed, err := rootExecuteCommand(root)
if err != nil {
if executed == nil {
executed = root
@@ -100,7 +125,7 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
@@ -255,9 +280,6 @@ func commandRequestsJSONErrors(cmd *cobra.Command) bool {
cmd.InheritedFlags(),
cmd.PersistentFlags(),
} {
if flags == nil {
continue
}
if flag := flags.Lookup("format"); flag != nil {
if value, err := flags.GetString("format"); err == nil && strings.EqualFold(strings.TrimSpace(value), "json") {
return true
@@ -379,7 +401,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := loadPlugins(engine, runner)
pluginCmds := rootLoadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
@@ -691,10 +713,10 @@ func configureOutputSink(cmd *cobra.Command) error {
if err := validateOptionalPath("--output", outputPath); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
}
file, err := os.Create(outputPath)
file, err := rootCreateFile(outputPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
}
@@ -708,7 +730,7 @@ func closeOutputSink(cmd *cobra.Command) error {
if !ok || file == nil {
return nil
}
if err := file.Close(); err != nil {
if err := rootCloseFile(file); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
}
return nil
@@ -727,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.
@@ -749,14 +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.
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
}
@@ -765,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
}
}
@@ -780,10 +818,10 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
// variables so that expandPluginVars can resolve ${KEY} references
// in plugin.json headers, endpoints, etc. User-set env vars take
// precedence (InjectPluginConfigEnv skips already-set keys).
pluginLoader.InjectPluginConfigEnv()
rootPluginInjectConfigEnv(pluginLoader)
// Load TokenData once; reused for stdio injection below.
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
tokenData, _ := rootAuthLoadTokenData(defaultConfigDir())
var userCtx *plugin.UserContext
if tokenData != nil {
// Inject user context if either UserID or CorpID is present.
@@ -796,32 +834,32 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
// 1. Load user plugins (per settings.json)
userPlugins := pluginLoader.LoadUser()
userPlugins := rootPluginLoadUser(pluginLoader)
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := pluginLoader.LoadDev()
devPlugins := rootPluginLoadDev(pluginLoader)
allPlugins := append(userPlugins, devPlugins...)
// 3. Register HTTP descriptors and authentication from the manifest.
for _, p := range allPlugins {
for _, srv := range p.ToServerDescriptors() {
registerPluginHTTPServer(srv)
for _, srv := range rootPluginDescriptors(p) {
rootRegisterPluginHTTPServer(srv)
}
}
// 4. Register stdio descriptors and unstarted clients. The subprocess is
// started and initialized only when a command is actually executed.
for _, p := range allPlugins {
for _, sc := range p.StdioClients(userCtx) {
registerStdioServerFromManifest(p, sc)
for _, sc := range rootPluginStdioClients(p, userCtx) {
rootRegisterStdioManifest(p, sc)
}
}
// 5. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
hooksCfg, err := p.LoadHooks()
hooksCfg, err := rootPluginLoadHooks(p)
if err != nil {
slog.Warn("plugin: failed to load hooks",
"plugin", p.Manifest.Name, "error", err)
@@ -837,7 +875,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
// 7. Sync plugin skills to agent directories
plugin.SyncSkills(allPlugins)
rootPluginSyncSkills(allPlugins)
if len(allPlugins) > 0 {
slog.Debug("plugins loaded",
+303
View File
@@ -0,0 +1,303 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
}
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
}
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
if code := Execute(); code != 5 {
t.Fatalf("panic Execute code = %d", code)
}
}
func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing.T) {
oldLoadPlugins := rootLoadPlugins
oldEdition := edition.Get()
oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
t.Cleanup(func() {
rootLoadPlugins = oldLoadPlugins
edition.Override(oldEdition)
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
})
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
}
preRunCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(*cobra.Command, []string) error { preRunCalled = true; return nil },
RegisterExtraCommands: func(root *cobra.Command, _ edition.ToolCaller) {
root.AddCommand(&cobra.Command{Use: "extra", Run: func(*cobra.Command, []string) {}})
},
})
root := NewRootCommandWithEngine(context.Background(), pipeline.NewEngine())
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version", "--client-id", "client", "--client-secret", "secret", "--debug"})
if err := root.Execute(); err != nil || !preRunCalled {
t.Fatalf("root version execution = %v preRun=%v", err, preRunCalled)
}
root = NewRootCommandWithEngine(context.Background(), nil)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(nil)
if err := root.Execute(); err != nil {
t.Fatalf("root help execution = %v", err)
}
standalone := newVersionCommand()
standalone.Flags().String("format", "", "")
standalone.SetOut(io.Discard)
standalone.SetArgs([]string{"--format", "json"})
version, buildTime, gitCommit = "1.2.3", "today", "commit"
edition.Override(&edition.Hooks{})
if err := standalone.Execute(); err != nil {
t.Fatalf("JSON version = %v", err)
}
root = NewRootCommandWithEngine(context.Background(), nil)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version", "--output", "bad\x00path"})
if err := root.Execute(); err == nil {
t.Fatal("unsafe output path succeeded")
}
}
func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *testing.T) {
t.Chdir(t.TempDir())
parent := &cobra.Command{Use: "root"}
parent.PersistentFlags().String("format", "json", "")
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
if !wantsJSONErrors(child) {
t.Fatal("root JSON format was not inherited")
}
localRoot := &cobra.Command{Use: "root"}
localRoot.Flags().String("format", "json", "")
localChild := &cobra.Command{Use: "child"}
localRoot.AddCommand(localChild)
if !wantsJSONErrors(localChild) {
t.Fatal("root-local JSON format was not recognized")
}
falseJSON := &cobra.Command{Use: "false-json"}
falseJSON.Flags().Bool("json", true, "")
_ = falseJSON.Flags().Set("json", "false")
if commandRequestsJSONErrors(falseJSON) {
t.Fatal("explicit false JSON flag requested JSON")
}
brokenJSON := &cobra.Command{Use: "broken"}
brokenJSON.Flags().String("json", "not-bool", "")
_ = brokenJSON.Flags().Set("json", "value")
if !commandRequestsJSONErrors(brokenJSON) {
t.Fatal("changed non-bool json flag was not treated as JSON")
}
pluginRoot := &cobra.Command{Use: "root"}
pluginRoot.AddCommand(&cobra.Command{Use: "market"})
addPluginCommandsSafe(pluginRoot, []*cobra.Command{
{Use: "auth"},
{Use: "duplicate"},
{Use: "duplicate"},
{Use: "market"},
})
oldMkdir := rootMkdirAll
oldCreate := rootCreateFile
oldClose := rootCloseFile
t.Cleanup(func() {
rootMkdirAll = oldMkdir
rootCreateFile = oldCreate
rootCloseFile = oldClose
})
wantErr := errors.New("filesystem")
newOutputCommand := func(path string) *cobra.Command {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("output", path, "")
cmd := &cobra.Command{Use: "output"}
root.AddCommand(cmd)
cmd.SetContext(context.Background())
return cmd
}
successPath := filepath.Join("success", "out")
successCmd := newOutputCommand(successPath)
if err := configureOutputSink(successCmd); err != nil {
t.Fatalf("output sink success = %v", err)
}
if err := closeOutputSink(successCmd); err != nil {
t.Fatalf("output sink close = %v", err)
}
badTypeRoot := &cobra.Command{Use: "root"}
badTypeRoot.PersistentFlags().Bool("output", false, "")
badTypeChild := &cobra.Command{Use: "child"}
badTypeRoot.AddCommand(badTypeChild)
if err := configureOutputSink(badTypeChild); err == nil {
t.Fatal("non-string output flag succeeded")
}
rootMkdirAll = func(string, os.FileMode) error { return wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("mkdir-failure", "out"))); err == nil {
t.Fatal("mkdir failure succeeded")
}
rootMkdirAll = func(string, os.FileMode) error { return nil }
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
t.Fatal("create failure succeeded")
}
rootCreateFile = oldCreate
file, err := os.CreateTemp(t.TempDir(), "close")
if err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "close"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
rootCloseFile = func(*os.File) error { return wantErr }
if err := closeOutputSink(cmd); err == nil {
t.Fatal("close failure succeeded")
}
if err := file.Close(); err != nil {
t.Fatalf("cleanup close-failure file = %v", err)
}
rootCloseFile = oldClose
file, err = os.CreateTemp(t.TempDir(), "close-success")
if err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
if err := closeOutputSink(cmd); err != nil {
t.Fatalf("close success = %v", err)
}
_ = file.Close()
for _, flags := range []*GlobalFlags{nil, {Debug: true}, {Verbose: true}, {}} {
configureLogLevel(flags)
}
}
func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
oldInject := rootPluginInjectConfigEnv
oldUser := rootPluginLoadUser
oldDev := rootPluginLoadDev
oldDescriptors := rootPluginDescriptors
oldStdioClients := rootPluginStdioClients
oldHTTP := rootRegisterPluginHTTPServer
oldStdio := rootRegisterStdioManifest
oldHooks := rootPluginLoadHooks
oldSync := rootPluginSyncSkills
oldToken := rootAuthLoadTokenData
t.Cleanup(func() {
rootPluginInjectConfigEnv = oldInject
rootPluginLoadUser = oldUser
rootPluginLoadDev = oldDev
rootPluginDescriptors = oldDescriptors
rootPluginStdioClients = oldStdioClients
rootRegisterPluginHTTPServer = oldHTTP
rootRegisterStdioManifest = oldStdio
rootPluginLoadHooks = oldHooks
rootPluginSyncSkills = oldSync
rootAuthLoadTokenData = oldToken
})
p1 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "one"}}
p2 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "two"}}
p3 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "three"}}
rootPluginInjectConfigEnv = func(*plugin.Loader) {}
rootPluginLoadUser = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p1, p2} }
rootPluginLoadDev = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p3} }
rootAuthLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{UserID: "user", CorpID: "corp"}, nil
}
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
if p == p1 {
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
}
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
}
client := transport.NewStdioClient("ignored", nil, nil)
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
if p == p1 && uc != nil && uc.UserID == "user" {
return []plugin.StdioServerClient{{Key: "local", Client: client}}
}
return nil
}
httpCount := 0
stdioCount := 0
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
stdioCount++
return mcptypes.ServerDescriptor{}
}
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
switch p {
case p1:
return nil, errors.New("hooks")
case p2:
return nil, nil
default:
return &plugin.HooksConfig{Hooks: []plugin.HookEntry{{Phase: "pre-request", Command: "true"}}}, nil
}
}
synced := false
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
t.Fatalf("loaded plugin commands = %#v", got)
}
if httpCount != 3 || stdioCount != 1 || !synced {
t.Fatalf("registered http=%d stdio=%d synced=%v", httpCount, stdioCount, synced)
}
}
-3
View File
@@ -183,9 +183,6 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
}
allowed := resolveVisibleProducts()
if len(allowed) == 0 {
return nil
}
commands := make([]*cobra.Command, 0)
for _, cmd := range root.Commands() {
+19
View File
@@ -47,6 +47,25 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
}
}
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"calendar", "event", "create", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("calendar event create --help: %v\n%s", err, out.String())
}
help := out.String()
if !strings.Contains(help, "--rooms string") {
t.Fatalf("calendar event create help missing string metavar for --rooms:\n%s", help)
}
if strings.Contains(help, "--rooms room search") {
t.Fatalf("calendar event create help treated description text as --rooms metavar:\n%s", help)
}
}
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
+37 -26
View File
@@ -20,6 +20,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -40,6 +41,9 @@ import (
)
func init() {
runnerHandlePatAuthCheck = handlePatAuthCheck
runnerRetryWithPatAuthRetry = retryWithPatAuthRetry
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_RUNTIME_CONTENT_SCAN",
Category: configmeta.CategoryRuntime,
@@ -157,6 +161,19 @@ type runtimeRunner struct {
auditSink audit.Sink
}
var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
runnerStdioCallTool = (*transport.StdioClient).CallTool
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
runnerCaptureRuntimeFailure = captureRuntimeFailure
)
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
// Global dry-run is an execution barrier, not merely a transport option.
// Return a deterministic local preview before profile resolution, catalog
@@ -176,7 +193,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
@@ -206,7 +223,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
if prefetchToken {
go getCachedRuntimeToken(ctx)
go runnerGetCachedRuntimeToken(ctx)
}
if shouldUseDirectRuntime(invocation) {
@@ -277,7 +294,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
return nil, false, nil
}
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
if p, err := runnerResolveProfile(configDir, rawSelector); err == nil && p != nil {
return nil, false, nil
}
@@ -289,7 +306,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
if selector == "" {
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
}
profile, err := authpkg.ResolveProfile(configDir, selector)
profile, err := runnerResolveProfile(configDir, selector)
if err != nil {
return nil, false, err
}
@@ -305,9 +322,6 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
Profile: *profile,
})
}
if len(selections) == 0 {
return nil, false, nil
}
return selections, true, nil
}
@@ -579,25 +593,25 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
defer cancel()
}
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
if err := runnerPreflightDocDownload(r, callCtx, tc, endpoint, invocation); err != nil {
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, err, err)
runnerCaptureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
callStart := time.Now()
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
RecordTiming(ctx, "mcp_call", time.Since(callStart))
if err != nil {
if isAuthError(err) {
if fn := edition.Get().OnAuthError; fn != nil {
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
captureRuntimeFailure(invocation, err, overrideErr)
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
return executor.Result{}, overrideErr
}
}
@@ -605,10 +619,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// PAT scope error: offer human-readable output and retry after authorization
if isPatScopeError(err) {
scopeErr := extractPatScopeError(err)
captureRuntimeFailure(invocation, err, err)
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
runnerCaptureRuntimeFailure(invocation, err, err)
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, err, err)
runnerCaptureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -619,7 +633,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck // already retried once, don't loop
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
return executor.Result{}, editionErr
}
@@ -630,7 +644,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck // already retried once, don't loop
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if callResult.IsError {
@@ -641,7 +655,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// patterns (PAT permission, gateway-auth) before generic handling.
if classify := edition.Get().ClassifyToolResult; classify != nil {
if hookErr := classify(callResult.Content); hookErr != nil {
captureRuntimeFailure(invocation, hookErr, hookErr)
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
return executor.Result{}, hookErr
}
}
@@ -657,10 +671,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, mcpErr, mcpErr)
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
return executor.Result{}, mcpErr
}
@@ -729,7 +743,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
defer cancel()
}
if err := client.EnsureInitialized(callCtx); err != nil {
if err := runnerStdioEnsureInitialized(client, callCtx); err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio initialize failed: %v", err),
apperrors.WithOperation("initialize"),
@@ -737,7 +751,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
)
}
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio call failed: %v", err),
@@ -898,9 +912,6 @@ func productEndpointOverride(productID string) (string, bool) {
func resolveIdentityHeaders() map[string]string {
id := authpkg.EnsureExists(defaultConfigDir())
headers := id.Headers()
if headers == nil {
headers = make(map[string]string)
}
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
+366
View File
@@ -0,0 +1,366 @@
package app
import (
"context"
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type runnerCoverageFallback struct {
result executor.Result
err error
}
func (f runnerCoverageFallback) Run(context.Context, executor.Invocation) (executor.Result, error) {
return f.result, f.err
}
func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
oldResolveMulti := runnerResolveMultiProfileSelections
oldResolveProfile := runnerResolveProfile
oldCachedToken := runnerGetCachedRuntimeToken
t.Cleanup(func() {
runnerResolveMultiProfileSelections = oldResolveMulti
runnerResolveProfile = oldResolveProfile
runnerGetCachedRuntimeToken = oldCachedToken
})
created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2})
if created.(*runtimeRunner).transport == nil {
t.Fatal("runner transport was not created")
}
wantErr := errors.New("profiles failed")
runnerResolveMultiProfileSelections = func(string, string) ([]multiProfileSelection, bool, error) {
return nil, false, wantErr
}
if _, err := (&runtimeRunner{}).Run(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("profile resolution error was accepted")
}
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
prefetched := make(chan struct{}, 1)
runnerGetCachedRuntimeToken = func(context.Context) string {
prefetched <- struct{}{}
return ""
}
r := &runtimeRunner{
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
transport: transport.NewClient(nil),
fallback: runnerCoverageFallback{},
}
directMiss := inv
directMiss.Kind = "helper_invocation"
if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) {
t.Fatalf("direct runtime miss load error = %v", err)
}
directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true}
if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true {
t.Fatalf("direct runtime hit = %#v, %v", got, err)
}
if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) {
t.Fatalf("runSingle error = %v", err)
}
<-prefetched
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
if selector == "a,b" {
return nil, errors.New("not a combined profile")
}
if selector == "a" {
return nil, wantErr
}
return nil, nil
}
if _, _, err := resolveMultiProfileSelections("", "a,b"); !errors.Is(err, wantErr) {
t.Fatalf("profile error = %v", err)
}
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
if selector == "a,b" {
return nil, errors.New("not a combined profile")
}
if selector == "a" {
return &authpkg.Profile{CorpID: "corp-a"}, nil
}
return nil, nil
}
if _, _, err := resolveMultiProfileSelections("", "a,b"); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("nil profile error = %v", err)
}
selections := []multiProfileSelection{
{Selector: "bad", Profile: authpkg.Profile{CorpID: "bad"}},
{Selector: "good", Profile: authpkg.Profile{CorpID: "good"}},
}
r = &runtimeRunner{fallback: runnerCoverageFallback{err: wantErr}}
result, err := r.runMultiProfile(context.Background(), inv, selections[:1])
if err != nil || result.Response == nil {
t.Fatalf("multi failure aggregation = %#v, %v", result, err)
}
r.fallback = runnerCoverageFallback{result: executor.Result{Response: map[string]any{"endpoint": "local", "value": 1}}}
result, err = r.runMultiProfile(context.Background(), inv, selections[1:])
if err != nil || result.Response == nil {
t.Fatalf("multi success aggregation = %#v, %v", result, err)
}
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
r = &runtimeRunner{
loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}},
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{DryRun: true},
fallback: runnerCoverageFallback{},
}
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
got, err := r.runSingle(context.Background(), inv, false)
if err != nil || got.Response["endpoint"] != "https://override.test" {
t.Fatalf("catalog override = %#v, %v", got, err)
}
}
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
oldEdition := edition.Get()
oldPreflight := runnerPreflightDocDownload
oldCall := runnerCallTool
oldHandle := runnerHandlePatAuthCheck
oldRetry := runnerRetryWithPatAuthRetry
oldCapture := runnerCaptureRuntimeFailure
t.Cleanup(func() {
edition.Override(oldEdition)
runnerPreflightDocDownload = oldPreflight
runnerCallTool = oldCall
runnerHandlePatAuthCheck = oldHandle
runnerRetryWithPatAuthRetry = oldRetry
runnerCaptureRuntimeFailure = oldCapture
})
pluginAuthMu.Lock()
oldPluginRegistry := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
pluginAuthMu.Lock()
pluginAuthRegistry = oldPluginRegistry
pluginAuthMu.Unlock()
})
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
}
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
}
r := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{Token: "token", Timeout: 1},
}
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}}
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("default auth execution = %#v, %v", got, err)
}
wantErr := errors.New("preflight")
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return wantErr
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
t.Fatalf("preflight error = %v", err)
}
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return patErr
}
retrying := context.WithValue(context.Background(), patRetryingKey, true)
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
t.Fatalf("retrying preflight PAT = %v", err)
}
handled := errors.New("handled PAT")
runnerHandlePatAuthCheck = func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) {
return executor.Result{}, handled
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
t.Fatalf("handled preflight PAT = %v", err)
}
runnerPreflightDocDownload = oldPreflight
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
}
authErr := apperrors.NewAuth("expired")
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, authErr
}
overrideErr := errors.New("auth override")
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return overrideErr }})
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, overrideErr) {
t.Fatalf("auth override = %v", err)
}
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return nil }})
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, authErr) {
t.Fatalf("auth passthrough = %v", err)
}
scopeErr := errors.New("missing_scope calendar:read")
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, scopeErr
}
retried := errors.New("scope retried")
runnerRetryWithPatAuthRetry = func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) {
return executor.Result{}, retried
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
t.Fatalf("scope retry = %v", err)
}
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
edition.Override(&edition.Hooks{})
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
t.Fatalf("generic call error = %v", err)
}
patContent := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f"}}
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: patContent}, nil
}
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return patErr }})
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
t.Fatalf("edition retry PAT = %v", err)
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
t.Fatalf("edition handled PAT = %v", err)
}
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return wantErr }})
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
t.Fatalf("edition classification = %v", err)
}
edition.Override(&edition.Hooks{})
if _, err := r.executeInvocation(retrying, "https://example.test", inv); err == nil {
t.Fatal("built-in retrying PAT succeeded")
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
t.Fatalf("built-in handled PAT = %v", err)
}
callCount := 0
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
callCount++
if callCount%2 == 0 {
return wantErr
}
return nil
}})
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "business"}}, nil
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
t.Fatalf("business hook = %v", err)
}
edition.Override(&edition.Hooks{})
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Text: "missing_scope mail:read"}}, Content: map[string]any{}}, nil
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
t.Fatalf("business scope retry = %v", err)
}
r.scanner = coverageScanner{report: safety.Report{Scanned: true, Findings: []safety.Finding{{Pattern: "bad"}}}}
r.enforceContentScan = true
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
}
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); err == nil {
t.Fatal("scan failure succeeded")
}
}
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
oldStdioInit := runnerStdioEnsureInitialized
oldStdioCall := runnerStdioCallTool
oldEdition := edition.Get()
t.Cleanup(func() {
runnerStdioEnsureInitialized = oldStdioInit
runnerStdioCallTool = oldStdioCall
edition.Override(oldEdition)
StopAllStdioClients()
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("stdio-product", client)
r := &runtimeRunner{globalFlags: &GlobalFlags{Timeout: 1}}
inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"}
wantErr := errors.New("stdio failed")
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr }
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
t.Fatalf("stdio initialize error = %v", err)
}
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
t.Fatalf("stdio call error = %v", err)
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil
}
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
t.Fatalf("stdio tool error = %v", err)
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
}
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio success = %#v, %v", got, err)
}
r.globalFlags.Token = " explicit "
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
t.Fatalf("explicit auth token = %q", got)
}
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
_, _ = fallback()
return "provided", nil
}})
r.globalFlags.Token = ""
if got := r.resolveAuthToken(context.Background()); got != "provided" {
t.Fatalf("provided auth token = %q", got)
}
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
t.Fatalf("runtime explicit token = %q", got)
}
t.Setenv(envDWSChannel, "channel")
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string { return headers },
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers["x-enterprise"] = "yes"
return headers
},
})
headers := resolveIdentityHeaders()
if headers["x-dws-channel"] != "channel" || headers["x-enterprise"] != "yes" {
t.Fatalf("identity headers = %#v", headers)
}
if got := detectBusinessError(map[string]any{"content": map[string]any{"success": false, "errorMsg": "nested"}}); got != "nested" {
t.Fatalf("nested business error = %q", got)
}
if got := detectBusinessError(map[string]any{"success": false, "data": map[string]any{"success": false, "errorMsg": "nested-first"}}); got != "nested-first" {
t.Fatalf("nested-first business error = %q", got)
}
}
@@ -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{
+59 -35
View File
@@ -35,6 +35,28 @@ import (
"github.com/spf13/cobra"
)
var (
skillLoadAccessToken = loadSkillAccessToken
skillDownloadToTmp = downloadSkillToTmpDir
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
skillNewRequest = http.NewRequestWithContext
skillLoadTokenData = authpkg.LoadTokenData
skillResolveTargetPath = resolveSkillTargetPath
skillFetchDownloadInfo = fetchSkillDownloadInfo
skillDownloadFile = downloadSkillFile
skillExtractZip = extractSkillZip
skillUserHomeDir = os.UserHomeDir
skillMkdirTemp = os.MkdirTemp
skillCreate = os.Create
skillCreateTemp = os.CreateTemp
skillRemoveAll = os.RemoveAll
skillRemove = os.Remove
skillMkdirAll = os.MkdirAll
skillOpenFile = os.OpenFile
skillCopy = io.Copy
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_SKILL_API_HOST",
@@ -48,12 +70,14 @@ func init() {
const (
// legacySkillAPIHost is the legacy skill market host used by the old cli.
legacySkillAPIHost = "https://mcp.dingtalk.com"
// skillDownloadEndpoint is the API endpoint for downloading skills.
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
skillDownloadTimeout = 5 * time.Minute
)
// skillDownloadEndpoint is variable so tests and private distributions can
// exercise the download flow without contacting the public service.
var skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// downloadSkillResponse represents the API response for skill download.
type downloadSkillResponse struct {
Success bool `json:"success"`
@@ -272,7 +296,7 @@ func newSkillAddHintCommand() *cobra.Command {
func runSkillGet(cmd *cobra.Command, args []string) error {
skillID, _ := cmd.Flags().GetString("skill-id")
accessToken, err := loadSkillAccessToken()
accessToken, err := skillLoadAccessToken()
if err != nil {
return err
}
@@ -280,7 +304,7 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken)
tmpDir, err := skillDownloadToTmp(cmd.Context(), apiURL, accessToken)
if err != nil {
return err
}
@@ -295,7 +319,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
if source == "" {
source, _ = cmd.Flags().GetString("scopes")
}
accessToken, err := loadSkillAccessToken()
accessToken, err := skillLoadAccessToken()
if err != nil {
return err
}
@@ -304,14 +328,14 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
if source != "" {
apiURL += "&source=" + url.QueryEscape(source)
}
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
req, err := skillNewRequest(cmd.Context(), http.MethodGet, apiURL, nil)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
resp, err := skillHTTPDo(client, req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
}
@@ -359,12 +383,12 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
}
// Resolve target path
destPath, err := resolveSkillTargetPath(target)
destPath, err := skillResolveTargetPath(target)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
accessToken, err := loadSkillAccessToken()
accessToken, err := skillLoadAccessToken()
if err != nil {
return err
}
@@ -376,7 +400,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
// Step 1: Get download URL from API
fmt.Fprintf(w, "正在获取技能信息...\n")
downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID)
downloadResp, err := skillFetchDownloadInfo(ctx, accessToken, skillID)
if err != nil {
return err
}
@@ -399,7 +423,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
// Step 2: Download the skill zip file
fmt.Fprintf(w, "正在下载技能...\n")
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
tempZipPath, err := skillDownloadFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
if err != nil {
return err
}
@@ -407,7 +431,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
// Step 3: Extract zip to destination
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
if err := extractSkillZip(tempZipPath, destPath); err != nil {
if err := skillExtractZip(tempZipPath, destPath); err != nil {
return err
}
@@ -419,7 +443,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
func loadSkillAccessToken() (string, error) {
configDir := defaultConfigDir()
tokenData, err := authpkg.LoadTokenData(configDir)
tokenData, err := skillLoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
return "", skillAuthError()
}
@@ -462,7 +486,7 @@ func resolveSkillTargetPath(target string) (string, error) {
return "", fmt.Errorf("unsupported target")
}
homeDir, err := os.UserHomeDir()
homeDir, err := skillUserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
@@ -474,7 +498,7 @@ func resolveSkillTargetPath(target string) (string, error) {
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := skillNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
@@ -483,7 +507,7 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
resp, err := skillHTTPDo(client, req)
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
apperrors.WithRetryable(true))
@@ -513,14 +537,14 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
}
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
req, err := skillNewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: skillDownloadTimeout}
resp, err := client.Do(req)
resp, err := skillHTTPDo(client, req)
if err != nil {
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
}
@@ -530,22 +554,22 @@ func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (str
return "", parseLegacySkillAPIError(resp)
}
tmpDir, err := os.MkdirTemp("", "dws-skill-*")
tmpDir, err := skillMkdirTemp("", "dws-skill-*")
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
}
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
destPath := filepath.Join(tmpDir, filename)
file, err := os.Create(destPath)
file, err := skillCreate(destPath)
if err != nil {
os.RemoveAll(tmpDir)
_ = skillRemoveAll(tmpDir)
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
os.RemoveAll(tmpDir)
if _, err := skillCopy(file, resp.Body); err != nil {
_ = skillRemoveAll(tmpDir)
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
}
return tmpDir, nil
@@ -584,7 +608,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
}
client := &http.Client{Timeout: skillDownloadTimeout}
resp, err := client.Do(req)
resp, err := skillHTTPDo(client, req)
if err != nil {
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
apperrors.WithRetryable(true))
@@ -600,21 +624,21 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
if fileName == "" {
fileName = "skill.zip"
}
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
tempFile, err := skillCreateTemp("", "dws-skill-*.zip")
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
}
tempPath := tempFile.Name()
// Copy response body to temp file
_, err = io.Copy(tempFile, resp.Body)
_, err = skillCopy(tempFile, resp.Body)
closeErr := tempFile.Close()
if err != nil {
os.Remove(tempPath)
_ = skillRemove(tempPath)
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
}
if closeErr != nil {
os.Remove(tempPath)
_ = skillRemove(tempPath)
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
}
@@ -624,7 +648,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
// extractSkillZip extracts a zip file to the destination directory.
func extractSkillZip(zipPath, destDir string) error {
// Ensure destination directory exists
if err := os.MkdirAll(destDir, 0755); err != nil {
if err := skillMkdirAll(destDir, 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
}
@@ -653,16 +677,16 @@ func extractZipFile(file *zip.File, destDir string) error {
if file.FileInfo().IsDir() {
// Use 0755 to ensure we have write permission for creating files inside
return os.MkdirAll(filePath, 0755)
return skillMkdirAll(filePath, 0755)
}
// Ensure parent directory exists with write permission
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
if err := skillMkdirAll(filepath.Dir(filePath), 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
}
// Extract file
srcFile, err := file.Open()
srcFile, err := skillOpenZipFile(file)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
}
@@ -673,13 +697,13 @@ func extractZipFile(file *zip.File, destDir string) error {
if fileMode&0600 == 0 {
fileMode = 0644
}
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
destFile, err := skillOpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
}
defer destFile.Close()
if _, err := io.Copy(destFile, srcFile); err != nil {
if _, err := skillCopy(destFile, srcFile); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
}
@@ -689,6 +713,6 @@ func extractZipFile(file *zip.File, destDir string) error {
// cleanupTempFile removes a temporary file, ignoring errors.
func cleanupTempFile(path string) {
if path != "" {
os.Remove(path)
_ = skillRemove(path)
}
}
@@ -0,0 +1,326 @@
package app
import (
"archive/zip"
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/spf13/cobra"
)
type skillErrorReadCloser struct{ err error }
func (r skillErrorReadCloser) Read([]byte) (int, error) { return 0, r.err }
func (skillErrorReadCloser) Close() error { return nil }
func skillCoverageResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func skillCoverageCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
cmd.SetOut(io.Discard)
cmd.Flags().String("skill-id", "id", "")
cmd.Flags().String("query", "query", "")
cmd.Flags().String("source", "", "")
cmd.Flags().String("scopes", "scope", "")
return cmd
}
func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.T) {
oldToken := skillLoadAccessToken
oldTmp := skillDownloadToTmp
oldHTTP := skillHTTPDo
oldNewRequest := skillNewRequest
oldTarget := skillResolveTargetPath
oldFetch := skillFetchDownloadInfo
oldDownload := skillDownloadFile
oldExtract := skillExtractZip
t.Cleanup(func() {
skillLoadAccessToken = oldToken
skillDownloadToTmp = oldTmp
skillHTTPDo = oldHTTP
skillNewRequest = oldNewRequest
skillResolveTargetPath = oldTarget
skillFetchDownloadInfo = oldFetch
skillDownloadFile = oldDownload
skillExtractZip = oldExtract
})
fail := errors.New("failure")
cmd := skillCoverageCommand()
skillLoadAccessToken = func() (string, error) { return "", fail }
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill get auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find request failure should propagate")
}
skillNewRequest = oldNewRequest
skillDownloadToTmp = func(context.Context, string, string) (string, error) { return "", fail }
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill get download error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "", fail }
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill find auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find network failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusBadRequest, "bad"), nil
}
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find HTTP failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, "{"), nil
}
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find malformed response should fail")
}
for _, body := range []string{
`{"success":false,"errorMsg":"message"}`,
`{"success":false,"errorCode":"code"}`,
`{"success":false}`,
} {
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, body), nil
}
if err := runSkillFind(cmd, nil); err == nil {
t.Fatalf("skill find API failure %s should propagate", body)
}
}
skillResolveTargetPath = func(string) (string, error) { return "", fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
t.Fatal("invalid skill target should fail")
}
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
skillLoadAccessToken = func() (string, error) { return "", fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill add auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill info error = %v", err)
}
for _, response := range []*downloadSkillResponse{
{Success: false, ErrorMsg: "message"},
{Success: false, ErrorCode: "code"},
{Success: false},
{Success: true},
{Success: true, Result: &downloadSkillResult{}},
} {
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return response, nil }
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
t.Fatalf("invalid download response %#v should fail", response)
}
}
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) {
return &downloadSkillResponse{Success: true, Result: &downloadSkillResult{DownloadURL: "url", FileName: "skill.zip"}}, nil
}
skillDownloadFile = func(context.Context, string, string) (string, error) { return "", fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill file download error = %v", err)
}
skillDownloadFile = func(context.Context, string, string) (string, error) { return "temp.zip", nil }
skillExtractZip = func(string, string) error { return fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill extraction error = %v", err)
}
skillExtractZip = func(string, string) error { return nil }
if err := runSkillAdd(cmd, []string{"id", "target"}); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
oldHTTP := skillHTTPDo
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
oldHome := skillUserHomeDir
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
t.Cleanup(func() {
skillHTTPDo = oldHTTP
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
skillUserHomeDir = oldHome
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
})
fail := errors.New("failure")
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
if _, err := loadSkillAccessToken(); err == nil {
t.Fatal("invalid skill access token succeeded")
}
skillLoadTokenData = oldLoadToken
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("download-info request failure should propagate")
}
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "token"); err == nil {
t.Fatal("skill-file request failure should propagate")
}
skillNewRequest = oldNewRequest
skillUserHomeDir = func() (string, error) { return "", fail }
if _, err := resolveSkillTargetPath("codex"); err == nil {
t.Fatal("skill target HOME error should fail")
}
t.Setenv("DWS_SKILL_API_HOST", "https://skill.test/")
if skillAPIHost() != "https://skill.test" {
t.Fatal("skill API override was not normalized")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("download-info network failure should propagate")
}
for _, status := range []int{http.StatusUnauthorized, http.StatusBadGateway} {
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(status, "body"), nil
}
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatalf("download-info HTTP %d should fail", status)
}
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: skillErrorReadCloser{err: fail}}, nil
}
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("download-info body failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, "{"), nil
}
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("malformed download-info should fail")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, "body"), nil
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
t.Fatal("temporary skill download network failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, "body"), nil
}
skillMkdirTemp = func(string, string) (string, error) { return "", fail }
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
t.Fatal("download temp-dir failure should propagate")
}
tmpDir := t.TempDir()
skillMkdirTemp = func(string, string) (string, error) { return tmpDir, nil }
skillCreate = func(string) (*os.File, error) { return nil, fail }
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
t.Fatal("download temp-file failure should propagate")
}
skillCreate = func(string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
t.Fatal("download save failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
t.Fatal("skill-file network failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusBadGateway, "body"), nil
}
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
t.Fatal("skill-file HTTP failure should propagate")
}
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
return skillCoverageResponse(http.StatusOK, "body"), nil
}
skillCreateTemp = func(string, string) (*os.File, error) { return nil, fail }
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
t.Fatal("skill-file temp failure should propagate")
}
skillCreateTemp = func(string, string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
t.Fatal("skill-file copy failure should propagate")
}
skillCopy = func(w io.Writer, _ io.Reader) (int64, error) {
_ = w.(*os.File).Close()
return 0, nil
}
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
t.Fatal("skill-file close failure should propagate")
}
skillMkdirAll = func(string, os.FileMode) error { return fail }
if err := extractSkillZip("missing", "dest"); err == nil {
t.Fatal("zip destination mkdir failure should propagate")
}
zipPath := filepath.Join(t.TempDir(), "files.zip")
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
_, _ = zw.Create("dir/")
w, _ := zw.Create("file")
_, _ = w.Write([]byte("content"))
header := &zip.FileHeader{Name: "mode-file", Method: zip.Store}
header.SetMode(0o111)
w, _ = zw.CreateHeader(header)
_, _ = w.Write([]byte("mode"))
_ = zw.Close()
if err := os.WriteFile(zipPath, buf.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatal(err)
}
defer zr.Close()
skillMkdirAll = func(string, os.FileMode) error { return fail }
if err := extractZipFile(zr.File[0], t.TempDir()); err == nil {
t.Fatal("zip directory mkdir failure should propagate")
}
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
t.Fatal("zip parent mkdir failure should propagate")
}
skillMkdirAll = func(string, os.FileMode) error { return nil }
skillOpenZipFile = func(*zip.File) (io.ReadCloser, error) { return nil, fail }
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
t.Fatal("zip source-open failure should propagate")
}
skillOpenZipFile = oldZipOpen
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
t.Fatal("zip destination-open failure should propagate")
}
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "out") }
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
t.Fatal("zip copy failure should propagate")
}
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, nil }
var usedMode os.FileMode
skillOpenFile = func(_ string, _ int, mode os.FileMode) (*os.File, error) {
usedMode = mode
return os.CreateTemp(t.TempDir(), "mode")
}
if err := extractZipFile(zr.File[2], t.TempDir()); err != nil || usedMode != 0o644 {
t.Fatalf("zip fallback mode = %v, %v", usedMode, err)
}
}
+20 -20
View File
@@ -18,6 +18,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -367,21 +368,11 @@ 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)
// Save a valid token
err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
})
if err != nil {
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
}
t.Cleanup(CloseFileLogger)
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "install", "skill-123", "invalid-target"})
@@ -390,7 +381,7 @@ func TestSkillInstallInvalidTarget(t *testing.T) {
cmd.SetOut(&out)
cmd.SetErr(&out)
err = cmd.Execute()
err := cmd.Execute()
if err == nil {
t.Error("Execute() should have failed for invalid target")
}
@@ -402,8 +393,13 @@ 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)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
// Ensure the config directory exists but has no token
if err := os.MkdirAll(configDir, 0755); err != nil {
@@ -682,14 +678,17 @@ func TestSkillSearchHelpUsesWukongSourceAndKeepsScopesHidden(t *testing.T) {
func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
t.Cleanup(CloseFileLogger)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
}, nil
}
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
var gotSources []string
var gotScopes []string
@@ -711,6 +710,7 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
run := func(args ...string) {
t.Helper()
defer CloseFileLogger()
cmd := NewRootCommand()
cmd.SetArgs(args)
var out bytes.Buffer
+66 -42
View File
@@ -41,6 +41,33 @@ const (
skillSetupModeMulti = "multi"
)
var (
skillSetupResolveMode = resolveSkillSetupMode
skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded
skillSetupResolveTargets = resolveSkillSetupTargets
skillSetupListMulti = listMultiSkillNames
skillSetupFilterMulti = filterMultiSkillNames
skillSetupConfirm = confirmSkillSetup
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat
skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd
skillSetupUserHomeDir = os.UserHomeDir
skillSetupRemoveAll = os.RemoveAll
skillSetupMkdirAll = os.MkdirAll
skillSetupWalk = filepath.Walk
skillSetupRel = filepath.Rel
skillSetupReadlink = os.Readlink
skillSetupOpen = os.Open
skillSetupOpenFile = os.OpenFile
skillSetupCopy = io.Copy
)
func newSkillSetupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "setup",
@@ -89,7 +116,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
out := cmd.OutOrStdout()
errOut := cmd.ErrOrStderr()
mode, err := resolveSkillSetupMode(mode, autoYes, out)
mode, err := skillSetupResolveMode(mode, autoYes, out)
if err != nil {
return err
}
@@ -98,13 +125,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode)
if err != nil {
return err
}
defer srcCleanup()
dests, err := resolveSkillSetupTargets(target, mode)
dests, err := skillSetupResolveTargets(target, mode)
if err != nil {
return err
}
@@ -112,14 +139,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
return listErr
}
if len(allMultiSkillNames) == 0 {
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
}
filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw)
if filterErr != nil {
return filterErr
}
@@ -142,7 +169,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
}
@@ -157,9 +184,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
var installed, skipped int
switch mode {
case skillSetupModeMono:
installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -297,7 +324,7 @@ func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
// listMultiSkillNames returns sorted names of subdirectories under src that
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
func listMultiSkillNames(src string) ([]string, error) {
entries, err := os.ReadDir(src)
entries, err := skillSetupReadDir(src)
if err != nil {
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
}
@@ -306,7 +333,7 @@ func listMultiSkillNames(src string) ([]string, error) {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
@@ -328,7 +355,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
}
if autoYes || !isInteractiveTerminal() {
if autoYes || !skillSetupInteractive() {
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
return skillSetupModeMono, nil
}
@@ -346,7 +373,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
Value(&choice),
),
)
if err := form.Run(); err != nil {
if err := skillSetupRunForm(form); err != nil {
return "", fmt.Errorf("交互式选择中止: %w", err)
}
return choice, nil
@@ -405,7 +432,7 @@ func skillSourceCandidates(explicit, subdir string) []string {
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
roots = append(roots, env, filepath.Join(env, "skills", subdir))
}
if exe, err := os.Executable(); err == nil {
if exe, err := skillSetupExecutable(); err == nil {
exeDir := filepath.Dir(exe)
roots = append(roots,
filepath.Join(exeDir, "skills", subdir),
@@ -413,13 +440,13 @@ func skillSourceCandidates(explicit, subdir string) []string {
filepath.Join(exeDir, "..", "share", "skills", "dws"),
)
}
if wd, err := os.Getwd(); err == nil {
if wd, err := skillSetupGetwd(); err == nil {
roots = append(roots, filepath.Join(wd, "skills", subdir))
}
// User-level cache populated by install.sh / install.ps1 / npm install.js
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
// source even when the user has no source checkout on disk.
if home, err := os.UserHomeDir(); err == nil {
if home, err := skillSetupUserHomeDir(); err == nil {
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
}
return roots
@@ -431,16 +458,16 @@ func isSkillSourceRoot(path, mode string) bool {
}
switch mode {
case skillSetupModeMono:
fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
fi, err := skillSetupStat(filepath.Join(path, "SKILL.md"))
return err == nil && !fi.IsDir()
case skillSetupModeMulti:
entries, err := os.ReadDir(path)
entries, err := skillSetupReadDir(path)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
return true
}
}
@@ -458,7 +485,7 @@ func isSkillSourceRoot(path, mode string) bool {
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
home, err := os.UserHomeDir()
home, err := skillSetupUserHomeDir()
if err != nil {
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
}
@@ -489,15 +516,12 @@ func detectExistingAgentHomes(home, mode string) []string {
base := filepath.Join(home, rel)
parent := filepath.Dir(base)
if i > 0 {
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
continue
}
}
out = append(out, agentHomeForMode(base, mode))
}
if len(out) == 0 {
out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
}
return out
}
@@ -530,7 +554,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
}
}
if !isInteractiveTerminal() {
if !skillSetupInteractive() {
return true, nil
}
@@ -544,7 +568,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
Value(&confirm),
),
)
if err := form.Run(); err != nil {
if err := skillSetupRunForm(form); err != nil {
return false, fmt.Errorf("确认中止: %w", err)
}
return confirm, nil
@@ -561,7 +585,7 @@ func mutualExclusionVictims(dest, mode string) []string {
case skillSetupModeMono:
// dest = <agent-home>/dws → agent-home = parent
agentHome := filepath.Dir(dest)
entries, err := os.ReadDir(agentHome)
entries, err := skillSetupReadDir(agentHome)
if err != nil {
return nil
}
@@ -576,7 +600,7 @@ func mutualExclusionVictims(dest, mode string) []string {
case skillSetupModeMulti:
// dest = <agent-home> → mono 残留是 dest/dws
monoPath := filepath.Join(dest, "dws")
if _, err := os.Stat(monoPath); err == nil {
if _, err := skillSetupStat(monoPath); err == nil {
return []string{monoPath}
}
return nil
@@ -588,7 +612,7 @@ func mutualExclusionVictims(dest, mode string) []string {
// Failures emit a warning to errOut but never abort the install.
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
for _, victim := range mutualExclusionVictims(dest, mode) {
if err := os.RemoveAll(victim); err != nil {
if err := skillSetupRemoveAll(victim); err != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
continue
}
@@ -602,17 +626,17 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
if err := os.RemoveAll(dest); err != nil {
if err := skillSetupRemoveAll(dest); err != nil {
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
skipped++
continue
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
if err := skillSetupMkdirAll(filepath.Dir(dest), 0o755); err != nil {
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
skipped++
continue
}
if err := copyDir(src, dest); err != nil {
if err := skillSetupCopyDir(src, dest); err != nil {
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
skipped++
continue
@@ -632,7 +656,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
if err := os.MkdirAll(dest, 0o755); err != nil {
if err := skillSetupMkdirAll(dest, 0o755); err != nil {
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
skipped += len(skillNames)
continue
@@ -641,12 +665,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
if err := os.RemoveAll(subDest); err != nil {
if err := skillSetupRemoveAll(subDest); err != nil {
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
skipped++
continue
}
if err := copyDir(subSrc, subDest); err != nil {
if err := skillSetupCopyDir(subSrc, subDest); err != nil {
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
skipped++
continue
@@ -659,22 +683,22 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
rel, err := skillSetupRel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
return skillSetupMkdirAll(target, info.Mode())
}
if info.Mode()&os.ModeSymlink != 0 {
// resolve symlink target and copy the underlying file
resolved, err := os.Readlink(path)
resolved, err := skillSetupReadlink(path)
if err != nil {
return err
}
@@ -688,22 +712,22 @@ func copyDir(src, dst string) error {
}
func copyFileContent(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
in, err := skillSetupOpen(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
_, err = skillSetupCopy(out, in)
return err
}
+22 -8
View File
@@ -23,6 +23,20 @@ import (
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
)
var (
embeddedSkillStat = func(name string) (fs.FileInfo, error) {
return fs.Stat(dwsroot.EmbeddedSkills, name)
}
embeddedSkillMkdirTemp = os.MkdirTemp
embeddedSkillRemoveAll = os.RemoveAll
embeddedSkillWalkDir = func(root string, fn fs.WalkDirFunc) error {
return fs.WalkDir(dwsroot.EmbeddedSkills, root, fn)
}
embeddedSkillReadFile = dwsroot.EmbeddedSkills.ReadFile
embeddedSkillMkdirAll = os.MkdirAll
embeddedSkillWriteFile = os.WriteFile
)
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
// override (validated as an on-disk dir). Otherwise it falls back to the skill
@@ -50,33 +64,33 @@ func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), e
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
noop := func() {}
sub := "skills/" + mode // embed.FS always uses forward slashes
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
if _, err := embeddedSkillStat(sub); err != nil {
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
}
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
tmp, err := embeddedSkillMkdirTemp("", "dws-skill-"+mode+"-")
if err != nil {
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tmp) }
cleanup := func() { _ = embeddedSkillRemoveAll(tmp) }
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
walkErr := embeddedSkillWalkDir(sub, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
dst := filepath.Join(tmp, filepath.FromSlash(rel))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
return embeddedSkillMkdirAll(dst, 0o755)
}
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
data, readErr := embeddedSkillReadFile(p)
if readErr != nil {
return readErr
}
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
if mkErr := embeddedSkillMkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
return mkErr
}
return os.WriteFile(dst, data, 0o644)
return embeddedSkillWriteFile(dst, data, 0o644)
})
if walkErr != nil {
cleanup()
@@ -0,0 +1,312 @@
package app
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
type skillSetupFileInfo struct {
name string
mode os.FileMode
}
func (i skillSetupFileInfo) Name() string { return i.name }
func (i skillSetupFileInfo) Size() int64 { return 0 }
func (i skillSetupFileInfo) Mode() os.FileMode { return i.mode }
func (i skillSetupFileInfo) ModTime() time.Time { return time.Time{} }
func (i skillSetupFileInfo) IsDir() bool { return i.mode.IsDir() }
func (i skillSetupFileInfo) Sys() any { return nil }
func skillSetupCoverageCommand(t *testing.T, mode string, yes bool) *cobra.Command {
t.Helper()
cmd := newSkillSetupCommand()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
root.AddCommand(cmd)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_ = cmd.Flags().Set("mode", mode)
_ = cmd.Flags().Set("yes", map[bool]string{true: "true", false: "false"}[yes])
return cmd
}
func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T) {
oldMode := skillSetupResolveMode
oldSource := skillSetupResolveSource
oldTargets := skillSetupResolveTargets
oldList := skillSetupListMulti
oldFilter := skillSetupFilterMulti
oldConfirm := skillSetupConfirm
oldMono := skillSetupInstallMono
oldMulti := skillSetupInstallMulti
t.Cleanup(func() {
skillSetupResolveMode = oldMode
skillSetupResolveSource = oldSource
skillSetupResolveTargets = oldTargets
skillSetupListMulti = oldList
skillSetupFilterMulti = oldFilter
skillSetupConfirm = oldConfirm
skillSetupInstallMono = oldMono
skillSetupInstallMulti = oldMulti
})
fail := errors.New("failure")
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil }
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
skillSetupListMulti = func(string) ([]string, error) { return nil, fail }
if err := skillSetupCoverageCommand(t, skillSetupModeMulti, true).RunE(skillSetupCoverageCommand(t, skillSetupModeMulti, true), nil); err == nil {
t.Fatal("multi list failure should propagate")
}
skillSetupListMulti = func(string) ([]string, error) { return nil, nil }
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("empty multi source should fail")
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi filter failure should propagate")
}
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("confirmation failure should propagate")
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupResolveMode = func(string, bool, io.Writer) (string, error) { return "unknown", nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("unknown resolved mode should fail")
}
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("mono install failure should propagate")
}
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi install failure should propagate")
}
}
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
t.Cleanup(func() {
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
skillSetupInteractive = func() bool { return true }
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := resolveSkillSetupMode("", false, io.Discard); err == nil {
t.Fatal("interactive mode failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
t.Fatalf("interactive default choice = %q, %v", got, err)
}
source := writeMultiSkillSource(t, []string{"dingtalk-doc"})
if err := os.WriteFile(filepath.Join(source, "README"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := listMultiSkillNames(source); err != nil || len(got) != 1 {
t.Fatalf("listed multi skills = %#v, %v", got, err)
}
t.Setenv("DWS_SKILL_SOURCE", source)
if got, err := resolveSkillSetupSource("", skillSetupModeMulti); err != nil || got != source {
t.Fatalf("environment skill source = %q, %v", got, err)
}
t.Setenv("DWS_SKILL_SOURCE", "")
legacyRoot := t.TempDir()
legacyMono := filepath.Join(legacyRoot, "skills", skillSetupModeMono)
if err := os.MkdirAll(legacyMono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyMono, "SKILL.md"), []byte("skill"), 0o600); err != nil {
t.Fatal(err)
}
skillSetupExecutable = func() (string, error) { return filepath.Join(legacyRoot, "dws"), nil }
skillSetupStat = oldStat
if got, err := resolveSkillSetupSource("", skillSetupModeMono); err != nil || got != legacyMono {
t.Fatalf("legacy executable source = %q, %v", got, err)
}
t.Setenv("DWS_SKILL_SOURCE", "env-source")
if got := skillSourceCandidates("", skillSetupModeMono); len(got) < 2 {
t.Fatalf("environment source candidates = %#v", got)
}
t.Setenv("DWS_SKILL_SOURCE", "")
skillSetupExecutable = func() (string, error) { return "", fail }
skillSetupGetwd = func() (string, error) { return "", fail }
skillSetupUserHomeDir = func() (string, error) { return "", fail }
if _, err := resolveSkillSetupSource("", skillSetupModeMono); err == nil {
t.Fatal("missing fallback source should fail")
}
_ = skillSourceCandidates("explicit", skillSetupModeMono)
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, fail }
if isSkillSourceRoot("missing", skillSetupModeMulti) {
t.Fatal("unreadable multi source accepted")
}
skillSetupUserHomeDir = func() (string, error) { return "", fail }
if _, err := resolveSkillSetupTargets("all", skillSetupModeMono); err == nil {
t.Fatal("HOME failure should propagate")
}
monoDest := filepath.Join(t.TempDir(), "skills", "dws")
multiRoot := filepath.Dir(monoDest)
if err := os.MkdirAll(filepath.Join(multiRoot, "dingtalk-doc"), 0o755); err != nil {
t.Fatal(err)
}
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
var out, errOut bytes.Buffer
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
t.Fatal("confirmation form failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
t.Fatalf("EOF confirmation = %v, %v", ok, err)
}
skillSetupRemoveAll = func(string) error { return fail }
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
skillSetupCopyDir = func(string, string) error { return fail }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono remove failure not skipped")
}
skillSetupRemoveAll = func(string) error { return nil }
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono mkdir failure not skipped")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
_, skipped, _ = installSkillToHomes("src", []string{"c"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono copy failure not skipped")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut)
if skipped != 2 {
t.Fatal("multi mkdir failure count mismatch")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
if skipped != 1 {
t.Fatal("multi remove failure count mismatch")
}
skillSetupRemoveAll = func(string) error { return nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
if skipped != 1 {
t.Fatal("multi copy failure count mismatch")
}
skillSetupWalk = func(string, filepath.WalkFunc) error { return fail }
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
t.Fatalf("walk failure = %v", err)
}
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
return fn("path", skillSetupFileInfo{name: "path"}, nil)
}
skillSetupRel = func(string, string) (string, error) { return "", fail }
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
t.Fatalf("relative-path failure = %v", err)
}
skillSetupRel = func(string, string) (string, error) { return "file", nil }
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
return fn("link", skillSetupFileInfo{name: "link", mode: os.ModeSymlink}, nil)
}
skillSetupReadlink = func(string) (string, error) { return "", fail }
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
t.Fatalf("readlink failure = %v", err)
}
for _, target := range []string{"relative-target", "/absolute-target"} {
skillSetupReadlink = func(string) (string, error) { return target, nil }
_ = copyDir("src", "dst")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
t.Fatalf("copy mkdir failure = %v", err)
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupOpen = func(string) (*os.File, error) { return nil, fail }
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
t.Fatalf("copy open failure = %v", err)
}
in, err := os.CreateTemp(t.TempDir(), "in")
if err != nil {
t.Fatal(err)
}
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
t.Fatalf("copy output-open failure = %v", err)
}
outFile, err := os.CreateTemp(t.TempDir(), "out")
if err != nil {
t.Fatal(err)
}
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return outFile, nil }
skillSetupCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
t.Fatalf("copy content failure = %v", err)
}
closed, err := os.CreateTemp(t.TempDir(), "closed")
if err != nil {
t.Fatal(err)
}
_ = closed.Close()
if isCharDevice(closed) {
t.Fatal("closed file is not a character device")
}
_ = fs.ValidPath("path")
}
+15 -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")
@@ -99,6 +99,10 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
}
func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
home := t.TempDir()
originalHome := skillSetupUserHomeDir
skillSetupUserHomeDir = func() (string, error) { return home, nil }
t.Cleanup(func() { skillSetupUserHomeDir = originalHome })
got, err := resolveSkillSetupTargets("claude", skillSetupModeMono)
if err != nil {
t.Fatalf("unexpected err: %v", err)
@@ -106,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], ".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 +123,10 @@ func TestResolveSkillSetupTargetsUnknown(t *testing.T) {
}
func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
home := t.TempDir()
originalHome := skillSetupUserHomeDir
skillSetupUserHomeDir = func() (string, error) { return home, nil }
t.Cleanup(func() { skillSetupUserHomeDir = originalHome })
got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti)
if err != nil {
t.Fatalf("unexpected err: %v", err)
@@ -125,11 +134,9 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if strings.HasSuffix(got[0], "/dws") {
t.Fatalf("multi target must not end with /dws, got %s", got[0])
}
if !strings.HasSuffix(got[0], ".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])
}
}
+4 -3
View File
@@ -26,6 +26,7 @@ const stdioEndpointScheme = "stdio://"
var (
stdioMu sync.RWMutex
stdioClients = make(map[string]*transport.StdioClient)
stopStdio = func(client *transport.StdioClient) error { return client.Stop() }
)
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
@@ -75,7 +76,7 @@ func StopAllStdioClients() {
stdioMu.Lock()
defer stdioMu.Unlock()
for id, client := range stdioClients {
if err := client.Stop(); err != nil {
if err := stopStdio(client); err != nil {
slog.Warn("failed to stop stdio client", "id", id, "error", err)
}
}
@@ -91,7 +92,7 @@ func StopStdioClient(productID string) bool {
if !ok {
return false
}
if err := client.Stop(); err != nil {
if err := stopStdio(client); err != nil {
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
}
delete(stdioClients, productID)
@@ -108,7 +109,7 @@ func StopStdioClientsByPlugin(pluginName string) int {
count := 0
for id, client := range stdioClients {
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
if err := client.Stop(); err != nil {
if err := stopStdio(client); err != nil {
slog.Warn("failed to stop stdio client", "id", id, "error", err)
}
delete(stdioClients, id)
+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)
}
+17 -7
View File
@@ -55,6 +55,16 @@ const (
perfReportFile = "latest.json"
)
var (
timingMarshalIndent = json.MarshalIndent
timingMkdirAll = os.MkdirAll
timingWriteFile = os.WriteFile
timingRemove = os.Remove
timingRename = os.Rename
timingReadFile = os.ReadFile
timingUserHomeDir = os.UserHomeDir
)
// timingContextKey is the context key for TimingCollector.
type timingContextKey struct{}
@@ -295,7 +305,7 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
}
report := tc.BuildReport(cliVersion, command)
data, err := json.MarshalIndent(report, "", " ")
data, err := timingMarshalIndent(report, "", " ")
if err != nil {
return
}
@@ -306,22 +316,22 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
if err := timingMkdirAll(dir, 0o700); err != nil {
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
_ = os.Remove(tmp)
if err := timingWriteFile(tmp, data, 0o600); err != nil {
_ = timingRemove(tmp)
return
}
_ = os.Rename(tmp, path)
_ = timingRename(tmp, path)
}
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
func LoadLatestReport() (*PerfReport, error) {
path := defaultPerfReportPath()
data, err := os.ReadFile(path)
data, err := timingReadFile(path)
if err != nil {
return nil, err
}
@@ -341,7 +351,7 @@ func resolvePerfReportPath(dest string) string {
}
func defaultPerfReportPath() string {
home, err := os.UserHomeDir()
home, err := timingUserHomeDir()
if err != nil {
return ""
}
+9 -4
View File
@@ -282,8 +282,9 @@ func TestWriteReportIfEnabled_Auto(t *testing.T) {
tmpHome := t.TempDir()
expected := filepath.Join(tmpHome, ".dws", "perf", "latest.json")
// Temporarily override HOME for defaultPerfReportPath
t.Setenv("HOME", tmpHome)
originalHome := timingUserHomeDir
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
t.Cleanup(func() { timingUserHomeDir = originalHome })
t.Setenv(PerfReportEnv, "auto")
tc := NewTimingCollector()
@@ -312,7 +313,9 @@ func TestWriteReportIfEnabled_NilCollector(t *testing.T) {
func TestLoadLatestReport(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
originalHome := timingUserHomeDir
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
t.Cleanup(func() { timingUserHomeDir = originalHome })
perfDir := filepath.Join(tmpHome, ".dws", "perf")
if err := os.MkdirAll(perfDir, 0o700); err != nil {
@@ -348,7 +351,9 @@ func TestLoadLatestReport(t *testing.T) {
func TestLoadLatestReport_NotFound(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
originalHome := timingUserHomeDir
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
t.Cleanup(func() { timingUserHomeDir = originalHome })
_, err := LoadLatestReport()
if err == nil {
+5 -1
View File
@@ -31,6 +31,10 @@ type toolCallerAdapter struct {
flags *GlobalFlags
}
var toolCallerDryRun = func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return (executor.EchoRunner{}).Run(ctx, invocation)
}
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
return &toolCallerAdapter{runner: runner, flags: flags}
}
@@ -43,7 +47,7 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
// without catalog, auth, Keychain, endpoint or transport access.
if a != nil && a.DryRun() {
inv.DryRun = true
result, err := (executor.EchoRunner{}).Run(ctx, inv)
result, err := toolCallerDryRun(ctx, inv)
if err != nil {
return nil, err
}
+94 -47
View File
@@ -31,6 +31,54 @@ var (
ugBoldGrn = tui.Success
)
type upgradeReleaseClient interface {
FetchLatestReleaseForTrack(upgrade.ReleaseTrack) (*upgrade.ReleaseInfo, error)
FetchReleaseByTag(string) (*upgrade.ReleaseInfo, error)
FetchReleaseVersions(upgrade.ReleaseTrack) ([]upgrade.VersionEntry, error)
}
type upgradeRollbackManager interface {
ListBackups() ([]upgrade.BackupInfo, error)
RollbackTo(upgrade.BackupInfo) error
Backup(string) (string, error)
Cleanup(int) error
}
var (
newUpgradeReleaseClient = func() upgradeReleaseClient { return upgrade.NewClient() }
newUpgradeRollback = func() upgradeRollbackManager { return upgrade.NewRollbackManager() }
ensureUpgradeDirs = upgrade.EnsureUpgradeDirectories
cleanupUpgradeStale = upgrade.CleanupStaleFiles
upgradeNeedsUpgrade = upgrade.NeedsUpgrade
findUpgradeBinary = upgrade.FindBinaryAsset
findUpgradeSkills = upgrade.FindSkillsAsset
findUpgradeChecksums = upgrade.FindChecksumsAsset
downloadUpgradeFile = upgrade.Download
downloadUpgradeProgress = upgrade.DownloadWithProgress
extractUpgradeZip = upgrade.ExtractZip
findExtractedBinary = upgrade.FindBinaryInDir
locateUpgradeSkill = upgrade.LocateSkillMD
replaceUpgradeSelf = upgrade.ReplaceSelf
installUpgradeSkills = upgrade.UpgradeSkillLocations
upgradeMkdirTemp = os.MkdirTemp
upgradeRemoveAll = os.RemoveAll
upgradeReadFile = os.ReadFile
upgradeMkdirAll = os.MkdirAll
verifyUpgradeFile = strictVerifyFile
extractUpgradeTarGz = extractTarGz
validateUpgradeBinary = validateNewBinary
upgradeStat = os.Stat
upgradeChmod = os.Chmod
upgradeTryExecVersion = tryExecVersion
upgradeRepairDarwin = repairDarwinBinary
upgradeRuntimeGOOS = runtime.GOOS
upgradeLookPath = exec.LookPath
upgradeCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
upgradeUserHomeDir = os.UserHomeDir
)
const defaultListLimit = 10
func newUpgradeCommand() *cobra.Command {
@@ -128,7 +176,7 @@ type upgradeOptions struct {
// --- dws upgrade --check ---
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
client := upgrade.NewClient()
client := newUpgradeReleaseClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
@@ -140,7 +188,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
}
currentVer := version
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
needsUpgrade := upgradeNeedsUpgrade(currentVer, latest.Version)
if format == "json" {
return writeJSON(cmd.OutOrStdout(), map[string]any{
@@ -184,7 +232,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
// runUpgradeList displays available versions. When limit > 0, only the most
// recent `limit` versions are shown; pass 0 to show all (--all flag).
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
client := upgrade.NewClient()
client := newUpgradeReleaseClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
@@ -264,7 +312,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.
// --- dws upgrade --rollback ---
func runUpgradeRollback(yes bool) error {
rm := upgrade.NewRollbackManager()
rm := newUpgradeRollback()
backups, err := rm.ListBackups()
if err != nil {
@@ -341,13 +389,13 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
if err := ensureUpgradeDirs(); err != nil {
return fmt.Errorf("初始化目录结构失败: %w", err)
}
upgrade.CleanupStaleFiles()
cleanupUpgradeStale()
client := upgrade.NewClient()
client := newUpgradeReleaseClient()
var release *upgrade.ReleaseInfo
var err error
@@ -365,7 +413,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
currentVer := version
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
if !opts.force && !upgradeNeedsUpgrade(currentVer, release.Version) {
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
return nil
}
@@ -384,11 +432,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
// any side effect (no backup, no download, no replace). Matches the global
// flag's contract: "预览操作内容,不实际执行".
if opts.dryRun {
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
binaryAsset, err := findUpgradeBinary(release.Assets)
if err != nil {
return err
}
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
return nil
}
@@ -404,21 +452,21 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
}
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
binaryAsset, err := findUpgradeBinary(release.Assets)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
tmpDir, err := upgradeMkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
if err != nil {
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
tmpDir, err = upgradeMkdirTemp("", "dws-upgrade-*")
if err != nil {
return fmt.Errorf("创建临时目录失败: %w", err)
}
}
defer os.RemoveAll(tmpDir)
defer upgradeRemoveAll(tmpDir)
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
const totalSteps = 5
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
@@ -431,7 +479,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
// --- Step 1: Backup ---
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
rm := upgrade.NewRollbackManager()
rm := newUpgradeRollback()
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
if backupErr != nil {
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
@@ -441,11 +489,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
// Fetch checksums.txt (needed for strict verification of both binary and skills)
var checksumsContent string
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
checksumsAsset := findUpgradeChecksums(release.Assets)
if checksumsAsset != nil {
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
if _, dlErr := downloadUpgradeFile(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
if data, readErr := upgradeReadFile(checksumsPath); readErr == nil {
checksumsContent = string(data)
}
}
@@ -457,7 +505,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Print(progressPrefix)
start := time.Now()
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
n, err := downloadUpgradeProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
func(percent float64, downloaded, total int64) {
bar := progressBar(percent)
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
@@ -471,12 +519,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
var skillsZipPath string
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
skillsAsset := findUpgradeSkills(release.Assets)
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
if _, dlErr := downloadUpgradeFile(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包下载失败: %w", dlErr)
}
@@ -486,12 +534,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
// --- Step 3: Verify SHA256 (binary + skills together) ---
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
if err := verifyUpgradeFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
return err
}
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
skillsAsset := findUpgradeSkills(release.Assets)
if err := verifyUpgradeFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
return err
}
}
@@ -500,22 +548,22 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s 解压并验证...", stepFmt(4))
extractDir := filepath.Join(tmpDir, "extracted")
if strings.HasSuffix(binaryAsset.Name, ".zip") {
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
if err := extractUpgradeZip(binaryArchivePath, extractDir); err != nil {
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
} else {
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
if err := extractUpgradeTarGz(binaryArchivePath, extractDir); err != nil {
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
}
binaryPath := upgrade.FindBinaryInDir(extractDir)
binaryPath := findExtractedBinary(extractDir)
if binaryPath == "" {
fmt.Println()
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
}
if err := validateNewBinary(binaryPath, release.Version); err != nil {
if err := validateUpgradeBinary(binaryPath, release.Version); err != nil {
fmt.Println()
return fmt.Errorf("验证失败: %w", err)
}
@@ -523,12 +571,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
var skillSrc string
if hasSkills {
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
os.MkdirAll(skillsExtractDir, 0755)
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
_ = upgradeMkdirAll(skillsExtractDir, 0755)
if err := extractUpgradeZip(skillsZipPath, skillsExtractDir); err != nil {
fmt.Println()
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
}
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
skillSrc = locateUpgradeSkill(skillsExtractDir)
if skillSrc == "" {
fmt.Println()
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
@@ -542,13 +590,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
// --- Step 5: Replace binary + install skills ---
fmt.Printf(" %s 替换并安装...", stepFmt(5))
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
if err := replaceUpgradeSelf(binaryPath); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("替换二进制失败: %w", err)
}
if hasSkills {
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
result, installErr := installUpgradeSkills(skillSrc)
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
@@ -626,24 +674,24 @@ func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent s
// validateNewBinary checks the downloaded binary is valid.
func validateNewBinary(binaryPath, expectedVersion string) error {
info, err := os.Stat(binaryPath)
info, err := upgradeStat(binaryPath)
if err != nil {
return fmt.Errorf("文件不存在: %w", err)
}
if info.Size() == 0 {
return fmt.Errorf("文件为空")
}
if err := os.Chmod(binaryPath, 0755); err != nil {
if err := upgradeChmod(binaryPath, 0755); err != nil {
return fmt.Errorf("设置执行权限失败: %w", err)
}
out, err := tryExecVersion(binaryPath)
out, err := upgradeTryExecVersion(binaryPath)
if err != nil {
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
out, err = tryExecVersion(binaryPath)
if upgradeRuntimeGOOS == "darwin" && isLikelyAMFIKill(err) {
if repairErr := upgradeRepairDarwin(binaryPath); repairErr == nil {
out, err = upgradeTryExecVersion(binaryPath)
}
}
if err != nil {
@@ -678,12 +726,12 @@ func isLikelyAMFIKill(err error) bool {
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
func repairDarwinBinary(binaryPath string) error {
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
_, _ = upgradeCommandOutput("xattr", "-d", "com.apple.quarantine", binaryPath)
if _, err := exec.LookPath("codesign"); err != nil {
if _, err := upgradeLookPath("codesign"); err != nil {
return fmt.Errorf("codesign 不可用: %w", err)
}
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
out, err := upgradeCommandOutput("codesign", "--force", "--sign", "-", binaryPath)
if err != nil {
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
}
@@ -692,9 +740,8 @@ func repairDarwinBinary(binaryPath string) error {
// extractTarGz extracts a .tar.gz file using the system tar command.
func extractTarGz(archivePath, destDir string) error {
os.MkdirAll(destDir, 0755)
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
if out, err := cmd.CombinedOutput(); err != nil {
_ = upgradeMkdirAll(destDir, 0755)
if out, err := upgradeCommandOutput("tar", "xzf", archivePath, "-C", destDir); err != nil {
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
}
return nil
@@ -867,7 +914,7 @@ func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
}
func shortenHome(path string) string {
homeDir, err := os.UserHomeDir()
homeDir, err := upgradeUserHomeDir()
if err != nil {
return path
}
+442
View File
@@ -0,0 +1,442 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type fakeUpgradeClient struct {
latest *upgradepkg.ReleaseInfo
latestErr error
tagged *upgradepkg.ReleaseInfo
taggedErr error
versions []upgradepkg.VersionEntry
versionsErr error
}
func (c *fakeUpgradeClient) FetchLatestReleaseForTrack(upgradepkg.ReleaseTrack) (*upgradepkg.ReleaseInfo, error) {
return c.latest, c.latestErr
}
func (c *fakeUpgradeClient) FetchReleaseByTag(string) (*upgradepkg.ReleaseInfo, error) {
return c.tagged, c.taggedErr
}
func (c *fakeUpgradeClient) FetchReleaseVersions(upgradepkg.ReleaseTrack) ([]upgradepkg.VersionEntry, error) {
return c.versions, c.versionsErr
}
type fakeUpgradeRollback struct {
backups []upgradepkg.BackupInfo
listErr error
backupErr error
rollbackErr error
cleaned bool
}
func (r *fakeUpgradeRollback) ListBackups() ([]upgradepkg.BackupInfo, error) {
return r.backups, r.listErr
}
func (r *fakeUpgradeRollback) RollbackTo(upgradepkg.BackupInfo) error { return r.rollbackErr }
func (r *fakeUpgradeRollback) Backup(string) (string, error) { return "backup", r.backupErr }
func (r *fakeUpgradeRollback) Cleanup(int) error {
r.cleaned = true
return nil
}
type upgradeFileInfo struct{ size int64 }
func (i upgradeFileInfo) Name() string { return "dws" }
func (i upgradeFileInfo) Size() int64 { return i.size }
func (i upgradeFileInfo) Mode() os.FileMode { return 0o755 }
func (i upgradeFileInfo) ModTime() time.Time { return time.Time{} }
func (i upgradeFileInfo) IsDir() bool { return false }
func (i upgradeFileInfo) Sys() any { return nil }
func TestCrossPlatformCoverageUpgradeRollbackAndCommandBranchesCoverage(t *testing.T) {
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
oldEdition := edition.Get()
oldStdin := os.Stdin
oldVersion := version
t.Cleanup(func() {
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
edition.Override(oldEdition)
os.Stdin = oldStdin
version = oldVersion
})
fail := errors.New("failure")
rb := &fakeUpgradeRollback{listErr: fail}
newUpgradeRollback = func() upgradeRollbackManager { return rb }
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
t.Fatalf("rollback list error = %v", err)
}
rb.listErr = nil
if err := runUpgradeRollback(true); err == nil {
t.Fatal("rollback without backups succeeded")
}
rb.backups = []upgradepkg.BackupInfo{{Version: "1.0.0", CreatedAt: time.Now()}}
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(write, "n\n")
_ = write.Close()
os.Stdin = read
if err := runUpgradeRollback(false); err != nil {
t.Fatalf("cancel rollback = %v", err)
}
_ = read.Close()
rb.rollbackErr = fail
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
t.Fatalf("rollback apply error = %v", err)
}
rb.rollbackErr = nil
if err := runUpgradeRollback(true); err != nil {
t.Fatalf("rollback success = %v", err)
}
client := &fakeUpgradeClient{
latest: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
tagged: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
versions: []upgradepkg.VersionEntry{{Version: "9.9.9"}, {Version: "9.9.8"}},
}
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
client.latestErr = fail
cmd := &cobra.Command{Use: "upgrade"}
cmd.SetOut(io.Discard)
if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
t.Fatalf("upgrade check error = %v", err)
}
client.latestErr = nil
client.versionsErr = fail
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
t.Fatalf("upgrade list error = %v", err)
}
client.versionsErr = nil
version = "9.9.9"
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err != nil {
t.Fatal(err)
}
if err := runUpgradeList(cmd, "table", 1, upgradepkg.ReleaseTrackRelease); err != nil {
t.Fatal(err)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
embedded := newUpgradeCommand()
if err := embedded.Execute(); err == nil || !strings.Contains(err.Error(), "embedded") {
t.Fatalf("unnamed embedded upgrade error = %v", err)
}
edition.Override(&edition.Hooks{})
for _, args := range [][]string{{"--list", "--all"}, {"--rollback", "--yes"}, {"--check"}} {
command := newUpgradeCommand()
if args[0] == "--rollback" {
command.Flags().Bool("yes", false, "")
}
command.SetOut(io.Discard)
command.SetArgs(args)
if err := command.Execute(); err != nil {
t.Fatalf("upgrade command %v = %v", args, err)
}
}
}
func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
oldEnsure, oldCleanup := ensureUpgradeDirs, cleanupUpgradeStale
oldNeeds, oldBinary, oldSkills, oldChecksums := upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums
oldDownload, oldProgress := downloadUpgradeFile, downloadUpgradeProgress
oldExtract, oldFind, oldLocate := extractUpgradeZip, findExtractedBinary, locateUpgradeSkill
oldReplace, oldInstall := replaceUpgradeSelf, installUpgradeSkills
oldTemp, oldRemove, oldRead, oldMkdir := upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll
oldVerify, oldTar, oldValidate := verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary
oldStdin := os.Stdin
t.Cleanup(func() {
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
ensureUpgradeDirs, cleanupUpgradeStale = oldEnsure, oldCleanup
upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums = oldNeeds, oldBinary, oldSkills, oldChecksums
downloadUpgradeFile, downloadUpgradeProgress = oldDownload, oldProgress
extractUpgradeZip, findExtractedBinary, locateUpgradeSkill = oldExtract, oldFind, oldLocate
replaceUpgradeSelf, installUpgradeSkills = oldReplace, oldInstall
upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll = oldTemp, oldRemove, oldRead, oldMkdir
verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary = oldVerify, oldTar, oldValidate
os.Stdin = oldStdin
})
fail := errors.New("stage failure")
binary := upgradepkg.GitHubAsset{Name: "dws.zip", BrowserDownloadURL: "binary"}
skills := upgradepkg.GitHubAsset{Name: "dws-skills.zip", BrowserDownloadURL: "skills"}
checksums := upgradepkg.GitHubAsset{Name: "checksums.txt", BrowserDownloadURL: "checksums"}
release := &upgradepkg.ReleaseInfo{Version: "9.9.9", Date: "2026-01-01", Prerelease: true, Assets: []upgradepkg.GitHubAsset{binary, skills, checksums}}
client := &fakeUpgradeClient{latest: release, tagged: release}
rb := &fakeUpgradeRollback{}
configure := func(stage string) {
client.latestErr, client.taggedErr = nil, nil
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
newUpgradeRollback = func() upgradeRollbackManager { return rb }
rb.backupErr, rb.cleaned = nil, false
ensureUpgradeDirs = func() error {
if stage == "ensure" {
return fail
}
return nil
}
cleanupUpgradeStale = func() {}
upgradeNeedsUpgrade = func(string, string) bool { return stage != "not-needed" }
findUpgradeBinary = func([]upgradepkg.GitHubAsset) (*upgradepkg.GitHubAsset, error) {
if stage == "find-binary" {
return nil, fail
}
asset := binary
if stage == "extract-tar" {
asset.Name = "dws.tar.gz"
}
return &asset, nil
}
findUpgradeSkills = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := skills; return &asset }
findUpgradeChecksums = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := checksums; return &asset }
tempCalls := 0
upgradeMkdirTemp = func(string, string) (string, error) {
tempCalls++
if stage == "temp-both" || (stage == "temp-fallback" && tempCalls == 1) {
return "", fail
}
return "/tmp/dws-upgrade-coverage", nil
}
upgradeRemoveAll = func(string) error { return nil }
if stage == "backup" {
rb.backupErr = fail
}
downloadUpgradeFile = func(url, _ string) (int64, error) {
if stage == "checksum-download" && url == "checksums" {
return 0, fail
}
if stage == "skills-download" && url == "skills" {
return 0, fail
}
return 4, nil
}
upgradeReadFile = func(string) ([]byte, error) {
if stage == "checksum-read" {
return nil, fail
}
return []byte("checksum"), nil
}
downloadUpgradeProgress = func(_ context.Context, _, _ string, progress func(float64, int64, int64)) (int64, error) {
progress(150, 10, 10)
if stage == "binary-download" || stage == "temp-fallback" {
return 0, fail
}
return 1024, nil
}
verifyCalls := 0
verifyUpgradeFile = func(string, string, string, string, string) error {
verifyCalls++
if stage == "verify-binary" && verifyCalls == 1 || stage == "verify-skills" && verifyCalls == 2 {
return fail
}
return nil
}
extractCalls := 0
extractUpgradeZip = func(string, string) error {
extractCalls++
if stage == "extract-binary" && extractCalls == 1 || stage == "extract-skills" && extractCalls == 2 {
return fail
}
return nil
}
extractUpgradeTarGz = func(string, string) error {
if stage == "extract-tar" {
return fail
}
return nil
}
findExtractedBinary = func(string) string {
if stage == "binary-missing" {
return ""
}
return "/tmp/new-dws"
}
validateUpgradeBinary = func(string, string) error {
if stage == "validate" {
return fail
}
return nil
}
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
locateUpgradeSkill = func(string) string {
if stage == "skill-missing" {
return ""
}
return "/tmp/SKILL.md"
}
replaceUpgradeSelf = func(string) error {
if stage == "replace" {
return fail
}
return nil
}
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
if stage == "install-failed-dir" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
}
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
}
}
for _, stage := range []string{
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
"success", "success-no-skills",
} {
t.Run(stage, func(t *testing.T) {
configure(stage)
opts := upgradeOptions{force: true, yes: true}
if stage == "tag-error" {
opts.targetVersion = "v9.9.9"
client.taggedErr = fail
}
if stage == "latest-error" {
client.latestErr = fail
}
if stage == "not-needed" {
opts.force = false
}
if stage == "cancel" {
opts.yes = false
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(write, "n\n")
_ = write.Close()
os.Stdin = read
defer read.Close()
}
if stage == "success-no-skills" {
opts.skipSkills = true
}
err := runUpgrade(context.Background(), opts)
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
if wantError && err == nil {
t.Fatalf("stage %s succeeded", stage)
}
if !wantError && err != nil {
t.Fatalf("stage %s failed: %v", stage, err)
}
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
t.Fatal("successful upgrade did not clean backups")
}
if stage == "success" {
command := newUpgradeCommand()
command.Flags().Bool("yes", false, "")
command.Flags().Bool("dry-run", false, "")
_ = command.Flags().Set("yes", "true")
_ = command.Flags().Set("force", "true")
_ = command.Flags().Set("skip-skills", "true")
if err := command.RunE(command, nil); err != nil {
t.Fatalf("default upgrade command = %v", err)
}
}
})
}
}
func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T) {
oldStat, oldChmod := upgradeStat, upgradeChmod
oldTry, oldRepair := upgradeTryExecVersion, upgradeRepairDarwin
oldGOOS := upgradeRuntimeGOOS
oldLook, oldCommand, oldMkdir, oldHome := upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir
t.Cleanup(func() {
upgradeStat, upgradeChmod = oldStat, oldChmod
upgradeTryExecVersion, upgradeRepairDarwin = oldTry, oldRepair
upgradeRuntimeGOOS = oldGOOS
upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir = oldLook, oldCommand, oldMkdir, oldHome
})
fail := errors.New("failure")
if _, err := oldCommand(filepath.Join(t.TempDir(), "missing-command")); err == nil {
t.Fatal("default command runner executed a missing command")
}
upgradeStat = func(string) (os.FileInfo, error) { return nil, fail }
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
t.Fatalf("binary stat error = %v", err)
}
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{}, nil }
if err := validateNewBinary("binary", "1.0"); err == nil || !strings.Contains(err.Error(), "为空") {
t.Fatalf("empty binary error = %v", err)
}
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{size: 1}, nil }
upgradeChmod = func(string, os.FileMode) error { return fail }
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
t.Fatalf("binary chmod error = %v", err)
}
upgradeChmod = func(string, os.FileMode) error { return nil }
upgradeTryExecVersion = func(string) ([]byte, error) { return nil, fail }
upgradeRepairDarwin = func(string) error { return fail }
if err := validateNewBinary("binary", "1.0"); err == nil {
t.Fatal("unexecutable binary succeeded")
}
upgradeRuntimeGOOS = "darwin"
calls := 0
upgradeTryExecVersion = func(string) ([]byte, error) {
calls++
if calls == 1 {
return nil, errors.New("signal: killed")
}
return []byte("version 1.0"), nil
}
upgradeRepairDarwin = func(string) error { return nil }
if err := validateNewBinary("binary", "1.0"); err != nil {
t.Fatalf("repaired binary = %v", err)
}
upgradeRuntimeGOOS = oldGOOS
upgradeTryExecVersion = func(string) ([]byte, error) { return []byte("different"), nil }
if err := validateNewBinary("binary", "1.0"); err != nil {
t.Fatalf("version mismatch warning = %v", err)
}
upgradeLookPath = func(string) (string, error) { return "", fail }
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
if err := repairDarwinBinary("binary"); !errors.Is(err, fail) {
t.Fatalf("codesign lookup error = %v", err)
}
upgradeLookPath = func(string) (string, error) { return "/codesign", nil }
upgradeCommandOutput = func(name string, _ ...string) ([]byte, error) {
if name == "codesign" {
return []byte("denied"), fail
}
return nil, nil
}
if err := repairDarwinBinary("binary"); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("codesign execution error = %v", err)
}
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
if err := repairDarwinBinary("binary"); err != nil {
t.Fatalf("codesign success = %v", err)
}
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
if err := extractTarGz("archive", "dest"); err == nil || !strings.Contains(err.Error(), "tar failed") {
t.Fatalf("tar error = %v", err)
}
upgradeUserHomeDir = func() (string, error) { return "", fail }
if shortenHome("/path") != "/path" {
t.Fatal("home lookup failure shortened path")
}
if got := parseChangelogEntries("abcdef12", 1); len(got) != 0 {
t.Fatalf("empty changelog entry = %#v", got)
}
}
+3
View File
@@ -580,6 +580,9 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
if strings.Contains(err.Error(), "signal: killed") {
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
}
t.Fatalf("validateNewBinary did not recover: %v", err)
}
+2 -1
View File
@@ -177,10 +177,11 @@ func TestRetention(t *testing.T) {
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
_, err := NewDateRotatingWriter(dir, 90)
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
defer writer.Close()
// Give async pruning a moment
time.Sleep(50 * time.Millisecond)
+9 -3
View File
@@ -23,6 +23,12 @@ import (
// other dws process, so concurrent writers cannot fork the chain.
type Chain struct{}
var (
chainReadAt = func(file *os.File, data []byte, offset int64) (int, error) { return file.ReadAt(data, offset) }
chainSeek = func(file *os.File, offset int64, whence int) (int64, error) { return file.Seek(offset, whence) }
chainMarshal = json.Marshal
)
// NewChain keeps the historical constructor signature. The directory argument
// is no longer needed because prev_hash is derived from the target file.
func NewChain(string) *Chain { return &Chain{} }
@@ -57,7 +63,7 @@ func lastRecordHash(f *os.File) (string, error) {
start = 0
}
buf := make([]byte, size-start)
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
if _, err := chainReadAt(f, buf, start); err != nil && err != io.EOF {
return "", err
}
@@ -90,7 +96,7 @@ func lastRecordHash(f *os.File) (string, error) {
}
func lastRecordHashFullScan(f *os.File) (string, error) {
if _, err := f.Seek(0, io.SeekStart); err != nil {
if _, err := chainSeek(f, 0, io.SeekStart); err != nil {
return "", err
}
scanner := bufio.NewScanner(f)
@@ -169,7 +175,7 @@ func stripHashFields(line []byte) []byte {
}
evt.PrevHash = ""
evt.Hash = ""
out, err := json.Marshal(evt)
out, err := chainMarshal(evt)
if err != nil {
return line
}
+422
View File
@@ -0,0 +1,422 @@
package audit
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCrossPlatformCoverageChainErrorAndLargeRecordCoverage(t *testing.T) {
dir := t.TempDir()
closed, err := os.Create(filepath.Join(dir, "closed"))
if err != nil {
t.Fatal(err)
}
if err := closed.Close(); err != nil {
t.Fatal(err)
}
if _, _, err := NewChain(dir).SealFromFile(closed, []byte("{}")); err == nil {
t.Fatal("SealFromFile on a closed file succeeded")
}
newFile := func(name, content string) *os.File {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
file, err := os.OpenFile(path, os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
return file
}
if hash, err := lastRecordHash(newFile("newlines", "\n\r\n")); err != nil || hash != "" {
t.Fatalf("lastRecordHash(newlines) = %q, %v", hash, err)
}
if _, err := lastRecordHash(newFile("invalid", "not-json\n")); err == nil {
t.Fatal("lastRecordHash(invalid) error = nil")
}
large := `{"padding":"` + strings.Repeat("x", 70*1024) + `","hash":"large-hash"}` + "\n"
if hash, err := lastRecordHash(newFile("large", large)); err != nil || hash != "large-hash" {
t.Fatalf("lastRecordHash(large) = %q, %v", hash, err)
}
if hash, err := lastRecordHashFullScan(newFile("scan-empty", "\n\n")); err != nil || hash != "" {
t.Fatalf("lastRecordHashFullScan(empty) = %q, %v", hash, err)
}
if _, err := lastRecordHashFullScan(newFile("scan-invalid", "not-json\n")); err == nil {
t.Fatal("lastRecordHashFullScan(invalid) error = nil")
}
tooLarge := strings.Repeat("x", 9*1024*1024)
if _, err := lastRecordHashFullScan(newFile("scan-too-large", tooLarge)); err == nil {
t.Fatal("lastRecordHashFullScan(oversize) error = nil")
}
if _, _, err := VerifyFile(filepath.Join(dir, "missing")); err == nil {
t.Fatal("VerifyFile(missing) error = nil")
}
if valid, line, err := VerifyFile(filepath.Join(dir, "invalid")); err == nil || valid || line != 1 {
t.Fatalf("VerifyFile(invalid) = %v, %d, %v", valid, line, err)
}
prevMismatch := filepath.Join(dir, "prev-mismatch")
if err := os.WriteFile(prevMismatch, []byte(`{"prev_hash":"wrong","hash":"wrong"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if valid, line, err := VerifyFile(prevMismatch); err == nil || valid || line != 1 {
t.Fatalf("VerifyFile(prev mismatch) = %v, %d, %v", valid, line, err)
}
oversize := filepath.Join(dir, "verify-too-large")
if err := os.WriteFile(oversize, []byte(tooLarge), 0o600); err != nil {
t.Fatal(err)
}
if valid, _, err := VerifyFile(oversize); err == nil || valid {
t.Fatalf("VerifyFile(oversize) = %v, %v", valid, err)
}
invalidBody := []byte("not-json")
if got := stripHashFields(invalidBody); string(got) != string(invalidBody) {
t.Fatalf("stripHashFields(invalid) = %q", got)
}
}
func TestCrossPlatformCoverageCollectionAndRotationCoverage(t *testing.T) {
for _, value := range []string{"1", "true", "on", "yes", "y"} {
t.Setenv(EnvAuditDebug, value)
if !DebugEnabled() {
t.Fatalf("DebugEnabled(%q) = false", value)
}
}
t.Setenv(EnvAuditDebug, "off")
if DebugEnabled() {
t.Fatal("DebugEnabled(off) = true")
}
for _, value := range []string{"0", "false", "off", "no", "n"} {
t.Setenv(EnvAudit, value)
if IsEnabled() {
t.Fatalf("IsEnabled(%q) = true", value)
}
}
t.Setenv(EnvAudit, "unexpected")
if !IsEnabled() {
t.Fatal("IsEnabled(unexpected) = false")
}
t.Setenv(EnvAudit, "0")
sink, err := BuildSink(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
if _, ok := sink.(NopSink); !ok {
t.Fatalf("disabled BuildSink() = %T", sink)
}
t.Setenv(EnvAudit, "1")
t.Setenv(EnvRetentionDays, "invalid")
t.Setenv(EnvForwardURL, "http://127.0.0.1:1")
t.Setenv(EnvForwardToken, "secret")
t.Setenv(EnvForwardRedact, "unexpected")
sink, err = BuildSink(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
if err := sink.Close(); err != nil {
t.Fatal(err)
}
t.Setenv(EnvRetentionDays, "0")
t.Setenv(EnvForwardRedact, string(RedactHashed))
sink, err = BuildSink(t.TempDir(), func(string, ...any) {})
if err != nil {
t.Fatal(err)
}
if err := sink.Close(); err != nil {
t.Fatal(err)
}
badConfig := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badConfig, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(EnvAuditDir, filepath.Join(badConfig, "child"))
if _, err := BuildSink(badConfig, nil); err == nil {
t.Fatal("BuildSink(unwritable path) error = nil")
}
if _, err := NewDateRotatingWriter(filepath.Join(badConfig, "child"), 1); err == nil {
t.Fatal("NewDateRotatingWriter(invalid dir) error = nil")
}
lockDir := t.TempDir()
if err := os.Mkdir(filepath.Join(lockDir, auditLockFile), 0o700); err != nil {
t.Fatal(err)
}
if _, err := NewDateRotatingWriter(lockDir, 1); err == nil {
t.Fatal("NewDateRotatingWriter(lock directory) error = nil")
}
dir := t.TempDir()
w, err := NewDateRotatingWriter(dir, 0)
if err != nil {
t.Fatal(err)
}
if w.Dir() != dir {
t.Fatalf("Dir() = %q", w.Dir())
}
w.pruneOldFiles()
file, release, err := w.beginAppend()
if err != nil {
t.Fatal(err)
}
release()
w.curDate = "old"
if _, release, err = w.beginAppend(); err != nil {
t.Fatal(err)
}
release()
if file == nil {
t.Fatal("beginAppend returned nil file")
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
openFailureDir := t.TempDir()
openFailureWriter, err := NewDateRotatingWriter(openFailureDir, 1)
if err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(openFailureDir, "audit-"+time.Now().Format("20060102")+".jsonl"), 0o700); err != nil {
t.Fatal(err)
}
if _, _, err := openFailureWriter.beginAppend(); err == nil {
t.Fatal("beginAppend(open failure) error = nil")
}
if err := openFailureWriter.Close(); err != nil {
t.Fatal(err)
}
fileErrorWriter, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
fileErrorWriter.file, err = os.CreateTemp(t.TempDir(), "closed")
if err != nil {
t.Fatal(err)
}
if err := fileErrorWriter.file.Close(); err != nil {
t.Fatal(err)
}
if err := fileErrorWriter.Close(); err == nil {
t.Fatal("Close(closed data file) error = nil")
}
lockErrorWriter, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
if err := lockErrorWriter.lock.Close(); err != nil {
t.Fatal(err)
}
if err := lockErrorWriter.Close(); err == nil {
t.Fatal("Close(closed lock file) error = nil")
}
sinkCloseWriter, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
if err := sinkCloseWriter.lock.Close(); err != nil {
t.Fatal(err)
}
if err := NewFileSink(sinkCloseWriter, NewChain(""), nil).Close(); err == nil {
t.Fatal("FileSink.Close(closed writer) error = nil")
}
(&DateRotatingWriter{dir: filepath.Join(t.TempDir(), "missing"), retention: 1}).pruneOldFiles()
pruneDir := t.TempDir()
if err := os.WriteFile(filepath.Join(pruneDir, "audit-bad.jsonl"), nil, 0o600); err != nil {
t.Fatal(err)
}
(&DateRotatingWriter{dir: pruneDir, retention: 1}).pruneOldFiles()
if _, err := LatestAuditFile(filepath.Join(dir, "missing")); err == nil {
t.Fatal("LatestAuditFile(missing) error = nil")
}
empty := t.TempDir()
if _, err := LatestAuditFile(empty); err == nil {
t.Fatal("LatestAuditFile(empty) error = nil")
}
if _, err := AuditFilesInRange(filepath.Join(empty, "missing"), "", ""); err == nil {
t.Fatal("AuditFilesInRange(missing) error = nil")
}
for name := range map[string]bool{
"audit-20260101.jsonl": true,
"audit-20260102.jsonl": true,
"audit-bad.jsonl": true,
"other": true,
} {
if err := os.WriteFile(filepath.Join(empty, name), nil, 0o600); err != nil {
t.Fatal(err)
}
}
files, err := AuditFilesInRange(empty, "20260102", "20260103")
if err != nil || len(files) != 1 || !strings.HasSuffix(files[0], "20260102.jsonl") {
t.Fatalf("AuditFilesInRange() = %v, %v", files, err)
}
}
func TestCrossPlatformCoverageSinkAndForwarderFailureCoverage(t *testing.T) {
originalMarshal, originalWrite := sinkMarshal, sinkWrite
originalForwardMarshal, originalRedact := forwardMarshal, forwardRedactEventJSON
originalReadAt, originalSeek, originalChainMarshal := chainReadAt, chainSeek, chainMarshal
originalLock, originalLockTimeout := rotateLockFile, rotateLockTimeout
t.Cleanup(func() {
sinkMarshal, sinkWrite = originalMarshal, originalWrite
forwardMarshal, forwardRedactEventJSON = originalForwardMarshal, originalRedact
chainReadAt, chainSeek, chainMarshal = originalReadAt, originalSeek, originalChainMarshal
rotateLockFile, rotateLockTimeout = originalLock, originalLockTimeout
})
chainReadAt = func(*os.File, []byte, int64) (int, error) { return 0, errors.New("read") }
readFailure, err := os.CreateTemp(t.TempDir(), "read-failure")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = readFailure.Close() })
if _, err := readFailure.WriteString("x"); err != nil {
t.Fatal(err)
}
if _, err := lastRecordHash(readFailure); err == nil {
t.Fatal("lastRecordHash(read failure) error = nil")
}
chainReadAt = originalReadAt
chainSeek = func(*os.File, int64, int) (int64, error) { return 0, errors.New("seek") }
if _, err := lastRecordHashFullScan(readFailure); err == nil {
t.Fatal("lastRecordHashFullScan(seek failure) error = nil")
}
chainSeek = originalSeek
chainMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
validEvent, err := originalMarshal(Event{})
if err != nil {
t.Fatal(err)
}
if got := stripHashFields(validEvent); string(got) != string(validEvent) {
t.Fatalf("stripHashFields(marshal failure) = %q", got)
}
chainMarshal = originalChainMarshal
writer, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
sink := NewFileSink(writer, NewChain(""), nil)
sinkMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if err := sink.Emit(&Event{}); err == nil || !strings.Contains(err.Error(), "marshal event") {
t.Fatalf("Emit(first marshal) error = %v", err)
}
calls := 0
sinkMarshal = func(value any) ([]byte, error) {
calls++
if calls == 2 {
return nil, errors.New("final marshal")
}
return originalMarshal(value)
}
if err := sink.Emit(&Event{}); err == nil || !strings.Contains(err.Error(), "marshal final event") {
t.Fatalf("Emit(final marshal) error = %v", err)
}
sinkMarshal = originalMarshal
sinkWrite = func(*os.File, []byte) (int, error) { return 0, errors.New("write") }
if err := sink.Emit(&Event{}); err == nil || !strings.Contains(err.Error(), "write event") {
t.Fatalf("Emit(write) error = %v", err)
}
sinkWrite = originalWrite
if err := writer.Close(); err != nil {
t.Fatal(err)
}
rotateLockFile = func(*os.File) error { return errors.New("locked") }
rotateLockTimeout = 0
blockedWriter, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
blockedSink := NewFileSink(blockedWriter, NewChain(""), nil)
if err := blockedSink.Emit(&Event{}); err == nil || !strings.Contains(err.Error(), "acquire writer") {
t.Fatalf("Emit(lock failure) error = %v", err)
}
rotateLockFile, rotateLockTimeout = originalLock, originalLockTimeout
if err := blockedWriter.Close(); err != nil {
t.Fatal(err)
}
sealDir := t.TempDir()
sealWriter, err := NewDateRotatingWriter(sealDir, 1)
if err != nil {
t.Fatal(err)
}
todayFile := filepath.Join(sealDir, "audit-"+time.Now().Format("20060102")+".jsonl")
if err := os.WriteFile(todayFile, []byte("invalid\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := NewFileSink(sealWriter, NewChain(""), nil).Emit(&Event{}); err == nil || !strings.Contains(err.Error(), "seal event") {
t.Fatalf("Emit(seal failure) error = %v", err)
}
if err := sealWriter.Close(); err != nil {
t.Fatal(err)
}
forwardServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
forwarding := NewHTTPForwarder(forwardServer.URL, "", RedactNone, nil)
forwardWriter, err := NewDateRotatingWriter(t.TempDir(), 1)
if err != nil {
t.Fatal(err)
}
forwardSink := NewFileSink(forwardWriter, NewChain(""), forwarding)
if err := forwardSink.Emit(&Event{}); err != nil {
t.Fatal(err)
}
if err := forwardSink.Close(); err != nil {
t.Fatal(err)
}
forwardServer.Close()
var reports []string
report := func(format string, args ...any) { reports = append(reports, fmt.Sprintf(format, args...)) }
forwarder := NewHTTPForwarder(":", "", RedactNone, report)
forwarder.send(Event{})
forwardMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
forwarder.send(Event{})
forwardMarshal = originalForwardMarshal
forwardRedactEventJSON = func(Event, RedactLevel) ([]byte, error) { return nil, errors.New("redact") }
forwarder.redact = RedactHashed
forwarder.send(Event{})
forwardRedactEventJSON = originalRedact
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token" {
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
}
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
forwarder = NewHTTPForwarder(server.URL, "token", RedactMinimal, report)
forwarder.send(Event{Actor: Actor{Name: "name"}})
forwarder = NewHTTPForwarder("http://127.0.0.1:1", "", RedactNone, report)
forwarder.timeout = 20 * time.Millisecond
forwarder.send(Event{})
if err := forwarder.Close(nil); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
forwarder.wg.Add(1)
if err := forwarder.Close(ctx); err == nil {
t.Fatal("Close(cancelled) error = nil")
}
forwarder.wg.Done()
if len(reports) < 5 {
t.Fatalf("forward reports = %v", reports)
}
if _, err := RedactEventJSON(Event{Actor: Actor{Name: "name"}}, RedactHashed); err != nil {
t.Fatal(err)
}
}
+7 -2
View File
@@ -20,6 +20,11 @@ type HTTPForwarder struct {
timeout time.Duration
}
var (
forwardMarshal = json.Marshal
forwardRedactEventJSON = RedactEventJSON
)
func NewHTTPForwarder(url, token string, redact RedactLevel, report func(string, ...any)) *HTTPForwarder {
if report == nil {
report = func(string, ...any) {}
@@ -75,9 +80,9 @@ func (f *HTTPForwarder) send(evt Event) {
var body []byte
var err error
if f.redact != RedactNone {
body, err = RedactEventJSON(evt, f.redact)
body, err = forwardRedactEventJSON(evt, f.redact)
} else {
body, err = json.Marshal(evt)
body, err = forwardMarshal(evt)
}
if err != nil {
f.report("forward marshal failed: %v", err)
+8 -3
View File
@@ -16,6 +16,11 @@ const (
auditLockRetry = 20 * time.Millisecond
)
var (
rotateLockFile = lockFile
rotateLockTimeout = auditLockTimeout
)
type DateRotatingWriter struct {
mu sync.Mutex
dir string
@@ -79,13 +84,13 @@ func (w *DateRotatingWriter) beginAppend() (*os.File, func(), error) {
}
func (w *DateRotatingWriter) acquireLock() error {
deadline := time.Now().Add(auditLockTimeout)
deadline := time.Now().Add(rotateLockTimeout)
for {
if err := lockFile(w.lock); err == nil {
if err := rotateLockFile(w.lock); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("audit: timeout acquiring file lock after %v (another dws process may be writing)", auditLockTimeout)
return fmt.Errorf("audit: timeout acquiring file lock after %v (another dws process may be writing)", rotateLockTimeout)
}
time.Sleep(auditLockRetry)
}
+9 -3
View File
@@ -4,9 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"time"
)
var (
sinkMarshal = json.Marshal
sinkWrite = func(file *os.File, data []byte) (int, error) { return file.Write(data) }
)
type Sink interface {
Emit(event *Event) error
Close() error
@@ -52,13 +58,13 @@ func (s *FileSink) Emit(evt *Event) error {
evt.PrevHash = prevHash
evt.Hash = hash
line, err := json.Marshal(evt)
line, err := sinkMarshal(evt)
if err != nil {
release()
return fmt.Errorf("audit: marshal final event: %w", err)
}
line = append(line, '\n')
if _, err := f.Write(line); err != nil {
if _, err := sinkWrite(f, line); err != nil {
release()
return fmt.Errorf("audit: write event: %w", err)
}
@@ -105,5 +111,5 @@ func marshalWithoutHash(evt *Event) ([]byte, error) {
OS: evt.OS,
Arch: evt.Arch,
}
return json.Marshal(saved)
return sinkMarshal(saved)
}
+20 -12
View File
@@ -55,6 +55,17 @@ var (
cachedAppConfigMu sync.RWMutex
)
var (
appConfigStoreSecret = StoreSecret
appConfigMarshalIndent = json.MarshalIndent
appConfigAtomicWrite = helpers.AtomicWriteJSON
appConfigReadFile = os.ReadFile
appConfigRemove = os.Remove
appConfigLoad = LoadAppConfig
appConfigResolveSecret = ResolveSecret
appConfigBeforeResolveLock = func() {}
)
// Cached resolved credentials (avoid repeated keychain access).
var (
cachedResolvedID string
@@ -98,7 +109,7 @@ func LoadAppConfig(configDir string) (*AppConfig, error) {
func SaveAppConfig(configDir string, config *AppConfig) error {
// Store plain secret in keychain, convert to reference
if config.ClientSecret.IsPlain() && config.ClientID != "" {
storedRef, err := StoreSecret(config.ClientID, config.ClientSecret)
storedRef, err := appConfigStoreSecret(config.ClientID, config.ClientSecret)
if err != nil {
return fmt.Errorf("storing client secret: %w", err)
}
@@ -111,13 +122,13 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
}
config.UpdatedAt = time.Now()
data, err := json.MarshalIndent(config, "", " ")
data, err := appConfigMarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
path := GetAppConfigPath(configDir)
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
if err := appConfigAtomicWrite(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
cleanupLegacySiblingAppConfig(configDir, config)
@@ -143,11 +154,7 @@ func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
}
legacyPath := filepath.Join(configDir, appConfigFile)
if legacyPath == GetAppConfigPath(configDir) {
return
}
data, err := os.ReadFile(legacyPath)
data, err := appConfigReadFile(legacyPath)
if err != nil {
return
}
@@ -160,7 +167,7 @@ func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
return
}
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
if err := appConfigRemove(legacyPath); err != nil && !os.IsNotExist(err) {
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
}
}
@@ -175,7 +182,7 @@ func DeleteAppConfig(configDir string) error {
// Remove config file
path := GetAppConfigPath(configDir)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
if err := appConfigRemove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing app config: %w", err)
}
@@ -215,7 +222,7 @@ func GetCachedAppConfig(configDir string) *AppConfig {
// ReloadAppConfig forces a reload of the app configuration from disk.
// This should be called after SaveAppConfig to ensure the cache is updated.
func ReloadAppConfig(configDir string) (*AppConfig, error) {
cfg, err := LoadAppConfig(configDir)
cfg, err := appConfigLoad(configDir)
if err != nil {
return nil, err
}
@@ -248,6 +255,7 @@ func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cachedResolvedMu.RUnlock()
// Slow path: load and cache
appConfigBeforeResolveLock()
cachedResolvedMu.Lock()
defer cachedResolvedMu.Unlock()
// Double-check after acquiring write lock
@@ -258,7 +266,7 @@ func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cfg := GetCachedAppConfig(configDir)
if cfg != nil {
cachedResolvedID = cfg.ClientID
if secret, err := ResolveSecret(cfg.ClientSecret); err == nil {
if secret, err := appConfigResolveSecret(cfg.ClientSecret); err == nil {
cachedResolvedSecret = secret
}
}
+15 -6
View File
@@ -36,6 +36,15 @@ const (
tokenExpiryBuffer = 5 * time.Minute
)
var (
appTokenMarshalIndent = json.MarshalIndent
appTokenMarshal = json.Marshal
appTokenNewRequest = http.NewRequestWithContext
appTokenKeychainSet = keychain.Set
appTokenKeychainGet = keychain.Get
appTokenKeychainRemove = keychain.Remove
)
// AppTokenData stores the app-level access token obtained from the unified
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
@@ -63,7 +72,7 @@ func SaveAppTokenData(data *AppTokenData) error {
return fmt.Errorf("clientID is required for saving app token data")
}
data.UpdatedAt = time.Now()
jsonData, err := json.MarshalIndent(data, "", " ")
jsonData, err := appTokenMarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal app token data: %w", err)
}
@@ -74,7 +83,7 @@ func SaveAppTokenData(data *AppTokenData) error {
}()
account := appTokenPrefix + data.ClientID
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
if err := appTokenKeychainSet(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save app token to keychain: %w", err)
}
return nil
@@ -87,7 +96,7 @@ func LoadAppTokenData(clientID string) (*AppTokenData, error) {
return nil, fmt.Errorf("clientID is required for loading app token data")
}
account := appTokenPrefix + clientID
jsonStr, err := keychain.Get(keychain.Service, account)
jsonStr, err := appTokenKeychainGet(keychain.Service, account)
if err != nil {
return nil, nil // Not found is not an error
}
@@ -108,7 +117,7 @@ func DeleteAppTokenData(clientID string) error {
return nil
}
account := appTokenPrefix + clientID
return keychain.Remove(keychain.Service, account)
return appTokenKeychainRemove(keychain.Service, account)
}
// --- Token Fetch Function ---
@@ -125,12 +134,12 @@ func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string,
"appKey": appKey,
"appSecret": appSecret,
}
bodyBytes, err := json.Marshal(body)
bodyBytes, err := appTokenMarshal(body)
if err != nil {
return "", 0, fmt.Errorf("marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
req, err := appTokenNewRequest(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", 0, fmt.Errorf("creating request: %w", err)
}
+14 -6
View File
@@ -22,7 +22,15 @@ import (
"runtime"
)
var browserStartCommand = func(name string, args ...string) error {
return exec.Command(name, args...).Start()
}
func openBrowser(rawURL string) error {
return openBrowserForOS(runtime.GOOS, rawURL)
}
func openBrowserForOS(goos, rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -31,14 +39,14 @@ func openBrowser(rawURL string) error {
return fmt.Errorf("refused to open URL with disallowed scheme %q", parsed.Scheme)
}
var cmd *exec.Cmd
switch runtime.GOOS {
var command string
switch goos {
case "darwin":
cmd = exec.Command("open", rawURL)
command = "open"
case "linux":
cmd = exec.Command("xdg-open", rawURL)
command = "xdg-open"
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
return fmt.Errorf("unsupported platform: %s", goos)
}
return cmd.Start()
return browserStartCommand(command, rawURL)
}
@@ -0,0 +1,63 @@
// 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.
//go:build darwin || linux
package auth
import (
"errors"
"strings"
"testing"
)
func TestCrossPlatformCoverageUnixBrowserCoverageEdges(t *testing.T) {
oldStart := browserStartCommand
t.Cleanup(func() { browserStartCommand = oldStart })
var calls []string
browserStartCommand = func(name string, args ...string) error {
calls = append(calls, name+":"+strings.Join(args, ","))
return nil
}
if err := openBrowserForOS("darwin", "https://example.test"); err != nil {
t.Fatal(err)
}
if err := openBrowserForOS("linux", "http://example.test"); err != nil {
t.Fatal(err)
}
if len(calls) != 2 || !strings.HasPrefix(calls[0], "open:") || !strings.HasPrefix(calls[1], "xdg-open:") {
t.Fatalf("browser calls = %v", calls)
}
if err := openBrowserForOS("plan9", "https://example.test"); err == nil {
t.Fatal("unsupported browser platform succeeded")
}
if err := openBrowserForOS("darwin", "://bad"); err == nil {
t.Fatal("invalid browser URL succeeded")
}
if err := openBrowserForOS("darwin", "file:///tmp/x"); err == nil {
t.Fatal("unsafe browser URL succeeded")
}
browserStartCommand = func(string, ...string) error { return errors.New("start") }
if err := openBrowserForOS("darwin", "https://example.test"); err == nil {
t.Fatal("browser start error was ignored")
}
browserStartCommand = func(string, ...string) error { return nil }
if err := openBrowser("https://example.test"); err != nil {
t.Fatal(err)
}
if err := oldStart("true"); err != nil {
t.Fatalf("default browser command hook = %v", err)
}
}
File diff suppressed because it is too large Load Diff
+47 -20
View File
@@ -43,6 +43,37 @@ const (
maxPollTotalWait = 10 * time.Minute
)
var (
deviceFlowAfter = time.After
deviceOpenBrowser = openBrowser
deviceFetchClientID = FetchClientIDFromMCP
deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) {
return p.loginOnce(ctx, attempt)
}
deviceRequestCode = func(p *DeviceFlowProvider, ctx context.Context) (*DeviceAuthResponse, error) {
return p.requestDeviceCode(ctx)
}
deviceWaitAuth = func(p *DeviceFlowProvider, ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
return p.waitForAuthorization(ctx, auth)
}
deviceExchangeCode = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) {
return p.exchangeCode(ctx, code)
}
deviceCheckCLIAuth = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) {
return p.CheckCLIAuthEnabled(ctx, token)
}
deviceGetAdmins = GetSuperAdmins
deviceSaveToken = SaveTokenData
deviceHasAppConfig = HasAppConfig
deviceSaveAppConfig = SaveAppConfig
devicePollStatus = func(p *DeviceFlowProvider, ctx context.Context, flowID string) (*DevicePollResponse, error) {
return p.pollDeviceStatus(ctx, flowID)
}
devicePollToken = func(p *DeviceFlowProvider, ctx context.Context, code string) (*DeviceTokenResponse, error) {
return p.pollDeviceToken(ctx, code)
}
)
type DeviceFlowProvider struct {
configDir string
clientID string
@@ -181,7 +212,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
mcpClientID, mcpErr := deviceFetchClientID(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -193,8 +224,8 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
}
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenData, err := p.loginOnce(ctx, attempt)
for attempt := 1; ; attempt++ {
tokenData, err := deviceLoginOnce(p, ctx, attempt)
if err == nil {
return tokenData, nil
}
@@ -205,21 +236,20 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
}
return nil, err
}
return nil, fmt.Errorf("%s", i18n.Tf("设备授权流程失败(已重试 %d 次)", maxAttempts))
}
func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*TokenData, error) {
dfPrintStep(p.output(), 1, i18n.T("请求设备授权码..."), attempt)
_, _ = fmt.Fprintln(p.output(), "")
authResp, err := p.requestDeviceCode(ctx)
authResp, err := deviceRequestCode(p, ctx)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("请求设备授权码失败"), err)
}
dfPrintDeviceCodeBox(p.output(), authResp)
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
if bErr := deviceOpenBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
p.logger.Debug("could not open browser", "error", bErr)
}
}
@@ -228,7 +258,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
dfPrintDim(p.output(), fmt.Sprintf(i18n.T(" (每 %d 秒轮询一次)"), authResp.Interval))
_, _ = fmt.Fprintln(p.output(), "")
tokenResult, err := p.waitForAuthorization(ctx, authResp)
tokenResult, err := deviceWaitAuth(p, ctx, authResp)
if err != nil {
return nil, err
}
@@ -241,14 +271,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
clientID: p.clientID,
logger: p.logger,
}
tokenData, err := oauthProvider.exchangeCode(ctx, tokenResult.AuthCode)
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Check if CLI auth is enabled for this organization (fail-closed: block on error)
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
authStatus, authErr := deviceCheckCLIAuth(oauthProvider, ctx, tokenData.AccessToken)
if authErr != nil {
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
@@ -299,7 +329,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
maxAdmins := 3
if len(admins.Result) < maxAdmins {
@@ -322,7 +352,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
@@ -334,8 +364,8 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
if !HasAppConfig(p.configDir) {
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
if !deviceHasAppConfig(p.configDir) {
_ = deviceSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
@@ -454,14 +484,14 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
case <-deviceFlowAfter(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
pollResp, err := devicePollStatus(p, ctx, auth.FlowID)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
@@ -505,14 +535,14 @@ func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Contex
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
case <-deviceFlowAfter(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
resp, err := p.pollDeviceToken(ctx, auth.DeviceCode)
resp, err := devicePollToken(p, ctx, auth.DeviceCode)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
@@ -655,9 +685,6 @@ func dfPrintBox(w io.Writer, lines []string) {
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
for _, line := range lines {
pad := maxLen - tui.PlainRuneWidth(line)
if pad < 0 {
pad = 0
}
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
}
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
+7 -4
View File
@@ -137,6 +137,9 @@ func GetMCPBaseURL() string {
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
authUserHomeDir = os.UserHomeDir
defaultAuthClientID = DefaultClientID
defaultAuthSecret = DefaultClientSecret
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
@@ -247,8 +250,8 @@ func ClientID() string {
return v
}
// Only return default if it's not a placeholder
if !strings.HasPrefix(DefaultClientID, "<") {
return DefaultClientID
if !strings.HasPrefix(defaultAuthClientID, "<") {
return defaultAuthClientID
}
return ""
}
@@ -272,7 +275,7 @@ func ClientSecret() string {
if v := os.Getenv("DWS_CLIENT_SECRET"); v != "" {
return v
}
return DefaultClientSecret
return defaultAuthSecret
}
// HasValidClientSecret returns true if a valid client secret is available.
@@ -301,7 +304,7 @@ func getDefaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
homeDir, err := os.UserHomeDir()
homeDir, err := authUserHomeDir()
if err != nil {
return ".dws"
}
+18 -8
View File
@@ -35,6 +35,16 @@ const (
var processLocks sync.Map // map[string]chan struct{}
var (
fileLockMkdirAll = os.MkdirAll
fileLockOpenFile = os.OpenFile
fileLockTry = lockFile
fileLockNow = time.Now
fileLockSleep = time.Sleep
dualAcquireProcessLock = acquireProcessLock
dualAcquireTokenLock = acquireTokenLock
)
// processLockKey generates a unique key for process-level locking.
func processLockKey(configDir string) string {
return "refresh:" + configDir
@@ -93,28 +103,28 @@ type tokenFileLock struct {
// It blocks (with timeout) if another process holds the lock.
// The caller MUST call release() when done.
func acquireTokenLock(configDir string) (*tokenFileLock, error) {
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
if err := fileLockMkdirAll(configDir, config.DirPerm); err != nil {
return nil, fmt.Errorf("creating config dir for lock: %w", err)
}
lockPath := filepath.Join(configDir, lockFileName)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, config.FilePerm)
f, err := fileLockOpenFile(lockPath, os.O_CREATE|os.O_RDWR, config.FilePerm)
if err != nil {
return nil, fmt.Errorf("opening lock file: %w", err)
}
deadline := time.Now().Add(config.LockTimeout)
deadline := fileLockNow().Add(config.LockTimeout)
for {
if err := lockFile(f); err == nil {
if err := fileLockTry(f); err == nil {
return &tokenFileLock{path: lockPath, file: f}, nil
}
if time.Now().After(deadline) {
if fileLockNow().After(deadline) {
_ = f.Close()
return nil, fmt.Errorf("timeout acquiring token lock after %v (another dws process may be running)", config.LockTimeout)
}
time.Sleep(lockRetryDelay)
fileLockSleep(lockRetryDelay)
}
}
@@ -145,13 +155,13 @@ type DualLock struct {
// The caller MUST call Release() when done.
func AcquireDualLock(ctx context.Context, configDir string) (*DualLock, error) {
// 1. Acquire process-level lock first (fast, in-memory)
processRelease, waited, err := acquireProcessLock(ctx, configDir)
processRelease, waited, err := dualAcquireProcessLock(ctx, configDir)
if err != nil {
return nil, fmt.Errorf("acquiring process lock: %w", err)
}
// 2. Acquire file-level lock (cross-process)
fileLock, err := acquireTokenLock(configDir)
fileLock, err := dualAcquireTokenLock(configDir)
if err != nil {
processRelease() // Release process lock on failure
return nil, fmt.Errorf("acquiring file lock: %w", err)
+19 -7
View File
@@ -39,6 +39,15 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
var (
identityReadFile = os.ReadFile
identityUnmarshal = json.Unmarshal
identityMkdirAll = os.MkdirAll
identityMarshalIndent = json.MarshalIndent
identityWriteFile = os.WriteFile
identityRandRead = rand.Read
)
const identityFile = "identity.json"
// identityVersion is the current on-disk schema version. v1 files (no
@@ -71,12 +80,12 @@ type Identity struct {
// v1 files are migrated in-memory (machineId backfilled from agentId).
func Load(configDir string) *Identity {
path := filepath.Join(configDir, identityFile)
data, err := os.ReadFile(path)
data, err := identityReadFile(path)
if err != nil {
return nil
}
var id Identity
if err := json.Unmarshal(data, &id); err != nil {
if err := identityUnmarshal(data, &id); err != nil {
return nil
}
if id.AgentID == "" && id.MachineID == "" {
@@ -182,15 +191,15 @@ func (id *Identity) Headers() map[string]string {
}
func save(configDir string, id *Identity) error {
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
if err := identityMkdirAll(configDir, config.DirPerm); err != nil {
return err
}
data, err := json.MarshalIndent(id, "", " ")
data, err := identityMarshalIndent(id, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
return identityWriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
}
// deriveAgentID computes a stable, client-side agentId for a (machine,
@@ -198,7 +207,10 @@ func save(configDir string, id *Identity) error {
// Deterministic and idempotent; no gateway allocation needed for statistics.
func deriveAgentID(seed, agentCode string) string {
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
enc := base62Encode(sum[:])
return formatDerivedAgentID(base62Encode(sum[:]))
}
func formatDerivedAgentID(enc string) string {
for len(enc) < 12 {
enc = "0" + enc
}
@@ -228,7 +240,7 @@ func base62Encode(b []byte) string {
// generateUUID produces a UUID v4 string.
func generateUUID() string {
var u [16]byte
if _, err := rand.Read(u[:]); err != nil {
if _, err := identityRandRead(u[:]); err != nil {
// Extremely unlikely; fallback to zero UUID rather than panic.
return "00000000-0000-4000-8000-000000000000"
}
+3 -1
View File
@@ -15,13 +15,15 @@ package auth
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
var authMigrateToFileDEK = keychain.MigrateToFileDEK
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
// refresh and login cannot rewrite an entry while its DEK backend is changing.
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
var migrated int
err := withProfilesLock(configDir, func() error {
var err error
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
migrated, err = authMigrateToFileDEK(keychain.Service, dryRun)
return err
})
return migrated, err
+25 -25
View File
@@ -26,8 +26,16 @@ import (
)
var (
migrationOnce sync.Once
migrationDone bool
migrationOnce sync.Once
migrationDone bool
authKeychainMarshal = json.MarshalIndent
authKeychainUnmarshal = json.Unmarshal
authKeychainSet = keychain.Set
authKeychainGet = keychain.Get
authKeychainRemove = keychain.Remove
authKeychainExists = keychain.Exists
authKeychainMigrate = keychain.MigrateFromLegacy
authValidateEntries = keychain.ValidateAuthTokenEntries
)
// ErrTokenDataNotFound means the requested keychain slot does not exist.
@@ -54,7 +62,7 @@ func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
jsonData, err := authKeychainMarshal(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
}
@@ -65,7 +73,7 @@ func saveTokenDataKeychainAccount(account string, data *TokenData) error {
}
}()
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
if err := authKeychainSet(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
@@ -86,7 +94,7 @@ func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, account)
jsonStr, err := authKeychainGet(keychain.Service, account)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
@@ -95,7 +103,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
}
var data TokenData
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
if err := authKeychainUnmarshal([]byte(jsonStr), &data); err != nil {
return nil, fmt.Errorf("parse token data: %w", err)
}
return &data, nil
@@ -119,17 +127,9 @@ func preflightTokenPersistence(configDir string) error {
if err != nil {
return fmt.Errorf("load token profiles: %w", err)
}
seen := make(map[string]struct{}, len(cfg.Profiles))
for _, profile := range cfg.Profiles {
corpID := strings.TrimSpace(profile.CorpID)
if corpID == "" {
continue
}
if _, ok := seen[corpID]; ok {
continue
}
seen[corpID] = struct{}{}
// LoadProfiles normalizes away blank and duplicate corp IDs.
corpID := profile.CorpID
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf(
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
@@ -137,7 +137,7 @@ func preflightTokenPersistence(configDir string) error {
)
}
}
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
if err := authValidateEntries(keychain.Service); err != nil {
return fmt.Errorf(
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
err,
@@ -169,7 +169,7 @@ func preflightTokenRefreshPersistence(data *TokenData) error {
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
return authKeychainRemove(keychain.Service, keychain.AccountToken)
}
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
@@ -178,12 +178,12 @@ func DeleteTokenDataKeychainForCorpID(corpID string) error {
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
return authKeychainRemove(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return keychain.Exists(keychain.Service, keychain.AccountToken)
return authKeychainExists(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
@@ -192,7 +192,7 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
if corpID == "" {
return false
}
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
return authKeychainExists(keychain.Service, TokenAccountForCorpID(corpID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
@@ -200,7 +200,7 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
// The migration is idempotent and thread-safe.
func EnsureMigration(configDir string, logger *slog.Logger) {
migrationOnce.Do(func() {
result := keychain.MigrateFromLegacy(configDir)
result := authKeychainMigrate(configDir)
migrationDone = true
if result.Migrated {
@@ -240,7 +240,7 @@ func SaveClientSecret(clientID, clientSecret string) error {
return nil // Nothing to save
}
account := clientSecretPrefix + clientID
if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
if err := authKeychainSet(keychain.Service, account, clientSecret); err != nil {
return fmt.Errorf("save client secret: %w", err)
}
return nil
@@ -253,7 +253,7 @@ func LoadClientSecret(clientID string) string {
return ""
}
account := clientSecretPrefix + clientID
secret, err := keychain.Get(keychain.Service, account)
secret, err := authKeychainGet(keychain.Service, account)
if err != nil {
return ""
}
@@ -266,5 +266,5 @@ func DeleteClientSecret(clientID string) error {
return nil
}
account := clientSecretPrefix + clientID
return keychain.Remove(keychain.Service, account)
return authKeychainRemove(keychain.Service, account)
}
+20 -13
View File
@@ -31,6 +31,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
var (
oauthSaveClientSecret = SaveClientSecret
oauthSaveTokenLocked = saveTokenDataLocked
oauthRetryAfter = time.After
oauthNewRequest = http.NewRequestWithContext
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
@@ -61,7 +68,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
data.ClientID = clientID
data.Source = resolveCredentialSource()
// Save clientSecret for future refresh (even if env changes)
if err := SaveClientSecret(clientID, clientSecret); err != nil {
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
// Log warning but don't fail login
fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err)
}
@@ -125,7 +132,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
clientSecret = ClientSecret()
}
if clientID == "" || clientSecret == "" {
if clientID == "" || clientSecret == "" || strings.HasPrefix(clientSecret, "<") {
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录")
}
@@ -156,7 +163,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -202,7 +209,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -213,7 +220,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
req, err := oauthNewRequest(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1362,7 +1369,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
@@ -1376,7 +1383,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1417,7 +1424,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doGetSuperAdmins(ctx, accessToken)
@@ -1431,7 +1438,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1465,7 +1472,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
@@ -1479,7 +1486,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1522,7 +1529,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
id, err := doFetchClientIDFromMCP(ctx)
@@ -1536,7 +1543,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
+54 -26
View File
@@ -36,6 +36,34 @@ var oauthHTTPClient = &http.Client{
Timeout: 15 * time.Second,
}
var (
oauthListen = net.Listen
oauthOpenBrowser = openBrowser
oauthLoginTimeout = 5 * time.Minute
oauthApprovalTimeout = 10 * time.Minute
oauthPollInterval = 5 * time.Second
oauthSuccessPause = 2 * time.Second
oauthLoadToken = LoadTokenData
oauthAcquireLock = AcquireDualLock
oauthMarkProfile = MarkProfileStatus
oauthFetchClientID = FetchClientIDFromMCP
oauthExchange = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) {
return p.exchangeCode(ctx, code)
}
oauthCheckStatus = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) {
return p.CheckCLIAuthEnabled(ctx, token)
}
oauthGetAdmins = GetSuperAdmins
oauthSendApply = SendCliAuthApply
oauthSaveToken = SaveTokenData
oauthHasAppConfig = HasAppConfig
oauthSaveAppConfig = SaveAppConfig
oauthRefreshToken = func(p *OAuthProvider, ctx context.Context, data *TokenData) (*TokenData, error) {
return p.refreshWithRefreshToken(ctx, data)
}
oauthSleep = time.Sleep
)
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
@@ -81,7 +109,7 @@ func (p *OAuthProvider) output() io.Writer {
func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, error) {
// Smart degradation: try silent refresh before opening browser.
if !force {
data, err := LoadTokenData(p.configDir)
data, err := oauthLoadToken(p.configDir)
if err == nil {
// Case 1: access_token still valid — no action needed.
if data.IsAccessTokenValid() {
@@ -129,7 +157,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
mcpClientID, mcpErr := oauthFetchClientID(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -141,7 +169,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Find a free port for the callback server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
listener, err := oauthListen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("starting callback listener: %w", err)
}
@@ -234,7 +262,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Exchange code for token
tokenData, exchangeErr := p.exchangeCode(ctx, code)
tokenData, exchangeErr := oauthExchange(p, ctx, code)
if exchangeErr != nil {
// Clear in-progress state on error
callbackTokenMu.Lock()
@@ -266,7 +294,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
callbackTokenMu.Unlock()
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
authStatus, statusErr := oauthCheckStatus(p, ctx, tokenData.AccessToken)
var denialReason string
if statusErr != nil {
denialReason = "unknown"
@@ -322,7 +350,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := GetSuperAdmins(ctx, token.AccessToken)
result, err := oauthGetAdmins(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -346,7 +374,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID)
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -382,7 +410,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken)
result, err := oauthCheckStatus(p, ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -417,7 +445,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
p.logger.Debug("authorization URL", "url", authURL)
}
if !p.NoBrowser {
if err := openBrowser(authURL); err != nil && p.logger != nil {
if err := oauthOpenBrowser(authURL); err != nil && p.logger != nil {
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
}
}
@@ -429,7 +457,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprintf(p.output(), i18n.T("如果浏览器未自动打开,请手动访问:\n %s\n\n"), authURL)
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 等待授权中..."))
timeout := time.NewTimer(5 * time.Minute)
timeout := time.NewTimer(oauthLoginTimeout)
defer timeout.Stop()
var result callbackResult
@@ -469,9 +497,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
// Poll for CLI auth status while waiting
applyTimeout := time.NewTimer(10 * time.Minute)
applyTimeout := time.NewTimer(oauthApprovalTimeout)
defer applyTimeout.Stop()
pollTicker := time.NewTicker(5 * time.Second)
pollTicker := time.NewTicker(oauthPollInterval)
defer pollTicker.Stop()
elapsedSeconds := 0
@@ -494,7 +522,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check if user switched to an org with CLI auth enabled
if currentToken != nil && !currentAuthDisabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
oauthSleep(oauthSuccessPause)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
@@ -502,10 +530,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check if CLI auth is now enabled (admin approved)
if currentToken != nil {
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
authStatus, err := oauthCheckStatus(p, ctx, currentToken.AccessToken)
if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
oauthSleep(oauthSuccessPause)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
@@ -527,7 +555,7 @@ continueLogin:
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
@@ -539,8 +567,8 @@ continueLogin:
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
if !HasAppConfig(p.configDir) {
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
if !oauthHasAppConfig(p.configDir) {
_ = oauthSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
@@ -551,7 +579,7 @@ continueLogin:
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
data, err := LoadTokenData(p.configDir)
data, err := oauthLoadToken(p.configDir)
if err != nil {
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
}
@@ -567,12 +595,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
} else {
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
@@ -599,7 +627,7 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
// refresh API, invalidating each other's refresh_token.
func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Acquire dual-layer lock (process-level + file-level)
lock, err := AcquireDualLock(ctx, p.configDir)
lock, err := oauthAcquireLock(ctx, p.configDir)
if err != nil {
return nil, fmt.Errorf("acquiring dual lock: %w", err)
}
@@ -607,7 +635,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Double-check: re-load from disk — another goroutine/process may have refreshed
// while we were waiting for the lock.
data, err := LoadTokenData(p.configDir)
data, err := oauthLoadToken(p.configDir)
if err != nil {
return nil, err
}
@@ -633,20 +661,20 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return p.refreshWithRefreshToken(ctx, data)
return oauthRefreshToken(p, ctx, data)
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
tokenData, err := p.exchangeCode(ctx, authCode)
tokenData, err := oauthExchange(p, ctx, authCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
if uid != "" {
tokenData.UserID = uid
}
if err := SaveTokenData(p.configDir, tokenData); err != nil {
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
return tokenData, nil
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More