Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03796388c3 | ||
|
|
833d0cc05e | ||
|
|
bdc480cf49 | ||
|
|
f8e1be5970 | ||
|
|
8d1ccd1b98 | ||
|
|
c84b5d05f4 | ||
|
|
669518682c | ||
|
|
642e676f79 | ||
|
|
da7b490e08 | ||
|
|
e2ab422787 | ||
|
|
4d05ea4fc1 | ||
|
|
e3bbb33c18 | ||
|
|
0d81f061d8 | ||
|
|
1c09115bd6 | ||
|
|
a0a4b5dfbe | ||
|
|
3f653d9da0 | ||
|
|
6e0917a3ed | ||
|
|
b5f431ba51 | ||
|
|
950de23e74 | ||
|
|
0108b1ca28 | ||
|
|
34aad4596c | ||
|
|
07d2e4597e | ||
|
|
57d753cd1d | ||
|
|
9e12da0219 | ||
|
|
7ca9ebeb57 | ||
|
|
d202d58963 | ||
|
|
4068847742 | ||
|
|
536fd66029 | ||
|
|
a519a2ff8a | ||
|
|
270771de0c | ||
|
|
3ec8320680 | ||
|
|
2c4d539a02 | ||
|
|
59abfc7dfd | ||
|
|
a676f3d606 | ||
|
|
c79c5dfffe | ||
|
|
681f2db87a | ||
|
|
7dc5b6f2ed | ||
|
|
f9b37486fd | ||
|
|
ad6e22d8cf | ||
|
|
05c0f1af1e | ||
|
|
605d9360fc | ||
|
|
fb4e6a0fdb | ||
|
|
2b715e35ad | ||
|
|
f56de38b79 | ||
|
|
c2e5fec967 | ||
|
|
f0642c73d7 | ||
|
|
c1bbc183ad | ||
|
|
579cd86c6c | ||
|
|
b6c85f31c4 | ||
|
|
88d82f201f | ||
|
|
b6df97fba1 | ||
|
|
a9ee1cd24d | ||
|
|
2614d225f2 | ||
|
|
e4faa0daa8 | ||
|
|
975f378559 | ||
|
|
28e83dfa57 | ||
|
|
7f07c22cd5 | ||
|
|
089a661124 | ||
|
|
d2f7c667e2 | ||
|
|
ff4961ebbe | ||
|
|
68d3c76d2d | ||
|
|
3811a0d82e | ||
|
|
e00019039c | ||
|
|
3fdf06fb51 | ||
|
|
ede677e413 | ||
|
|
b5abe6d328 | ||
|
|
51f531c3fd | ||
|
|
8d21510aec | ||
|
|
1a51f3a8da | ||
|
|
4d284c3740 | ||
|
|
fe5f484c29 | ||
|
|
8f4ab176a8 | ||
|
|
d288820b64 | ||
|
|
22d19863fb | ||
|
|
c02df07b64 | ||
|
|
e26e96eadc | ||
|
|
5b746f610a | ||
|
|
74fa24ee1e | ||
|
|
6a0cdbc323 | ||
|
|
397654890e | ||
|
|
a945663674 | ||
|
|
0201340b28 | ||
|
|
e75df36dfc | ||
|
|
648d604757 | ||
|
|
d78de010ff | ||
|
|
de35b08c8c | ||
|
|
e0dc26c8c7 | ||
|
|
adc87a92e4 | ||
|
|
d23910ce35 | ||
|
|
084188c7ac | ||
|
|
1822b82232 | ||
|
|
44403e4d23 | ||
|
|
ec29dc0e22 | ||
|
|
9f0966c05a | ||
|
|
bf105d3d29 | ||
|
|
48681eb41c | ||
|
|
5e41b8a6e8 | ||
|
|
296e9a73f0 | ||
|
|
3929719b51 | ||
|
|
d8ffea02ab | ||
|
|
87ac7048bd | ||
|
|
fafb6f47b9 | ||
|
|
8633246eff | ||
|
|
5fde6222d4 | ||
|
|
00bb595768 |
@@ -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 @@
|
||||
<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
@@ -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" \
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 }}
|
||||
+802
-117
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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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+"╯"))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user