Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b50cc41c5 | ||
|
|
11e50662f9 | ||
|
|
29abdb6e79 | ||
|
|
6196e2565e | ||
|
|
609d56305e | ||
|
|
e69a1084a7 | ||
|
|
978ee6e636 | ||
|
|
987c63d99c | ||
|
|
e565746fb7 | ||
|
|
4f76d7cb4c | ||
|
|
e94f230236 | ||
|
|
5242a0e1b1 | ||
|
|
c3e57b874b | ||
|
|
fa558372d2 | ||
|
|
ec9ae33a43 | ||
|
|
2b49a2f365 | ||
|
|
ae9b14e536 | ||
|
|
996c4ab250 | ||
|
|
cf36ccb46e | ||
|
|
361115956f | ||
|
|
e82574cdde | ||
|
|
b2f917aa47 | ||
|
|
7cb0398bae | ||
|
|
91bd7c7802 | ||
|
|
5a8376ac0f | ||
|
|
31c984e18c | ||
|
|
69c0eb1a49 | ||
|
|
82e98d98a3 | ||
|
|
ef509ecdeb | ||
|
|
8a7e1c7be7 | ||
|
|
f59be6c19a | ||
|
|
fbc2575c93 | ||
|
|
58cb4789cd | ||
|
|
63b5fe3143 | ||
|
|
41a65f268f | ||
|
|
03796388c3 | ||
|
|
69b31da4ba | ||
|
|
833d0cc05e | ||
|
|
b7cbef1c6f | ||
|
|
bdc480cf49 | ||
|
|
43eaadcf07 | ||
|
|
f8e1be5970 | ||
|
|
8d1ccd1b98 | ||
|
|
c84b5d05f4 | ||
|
|
5224d9c527 | ||
|
|
e9360fe11b | ||
|
|
2d143589f8 | ||
|
|
93854178e3 | ||
|
|
c7c9a6f926 | ||
|
|
669518682c | ||
|
|
642e676f79 | ||
|
|
a0224e1cbd | ||
|
|
da7b490e08 | ||
|
|
e2ab422787 | ||
|
|
4d05ea4fc1 | ||
|
|
e3bbb33c18 | ||
|
|
0d81f061d8 | ||
|
|
cd22cfb530 | ||
|
|
950de23e74 | ||
|
|
adc528c206 | ||
|
|
07d2e4597e | ||
|
|
57d753cd1d | ||
|
|
d202d58963 | ||
|
|
c79c5dfffe | ||
|
|
681f2db87a | ||
|
|
7dc5b6f2ed | ||
|
|
fb4e6a0fdb | ||
|
|
88d82f201f | ||
|
|
e00019039c | ||
|
|
74fa24ee1e | ||
|
|
a2f1e79603 | ||
|
|
1822b82232 | ||
|
|
ec29dc0e22 | ||
|
|
bf105d3d29 | ||
|
|
48681eb41c | ||
|
|
5e41b8a6e8 | ||
|
|
296e9a73f0 | ||
|
|
1adb4bc681 | ||
|
|
723c577484 | ||
|
|
766930f6e7 | ||
|
|
2e3311c955 | ||
|
|
1b4bb6b498 | ||
|
|
368e439280 | ||
|
|
8965fd2707 | ||
|
|
c0a7ad88a4 | ||
|
|
b62b1848aa | ||
|
|
5dd7f9abd3 | ||
|
|
eefee3c063 | ||
|
|
6f5d17335b |
+248
-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
|
||||
@@ -54,10 +58,18 @@ jobs:
|
||||
- name: Check GitHub Actions workflows
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test:
|
||||
name: Test
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -71,19 +83,85 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' }}
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
# The registry-first final-delivery gate validates all public commands
|
||||
# and the complete generated Catalog under the race detector. Keep the
|
||||
# package timeout aligned with the macOS race job so the Linux runner's
|
||||
# five-minute default does not expire while that gate is still making
|
||||
# progress.
|
||||
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
|
||||
- name: Test shard with Race Detection
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
case "$TEST_SHARD" in
|
||||
app)
|
||||
packages=(./internal/app/...)
|
||||
;;
|
||||
generators)
|
||||
packages=(./internal/generator/...)
|
||||
;;
|
||||
helpers)
|
||||
packages=(./internal/helpers/...)
|
||||
;;
|
||||
remaining)
|
||||
mapfile -t packages < <(
|
||||
go list ./cmd/... ./internal/... |
|
||||
grep -Ev '/internal/(app|generator|helpers)(/|$)'
|
||||
)
|
||||
;;
|
||||
*)
|
||||
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (release scripts)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs:
|
||||
- test-race
|
||||
- test-release-scripts
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
RACE_RESULT: ${{ needs.test-race.result }}
|
||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
@@ -119,8 +197,8 @@ jobs:
|
||||
- name: Test Windows auth and DPAPI packages
|
||||
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
|
||||
- name: Test Windows auth migration diagnostics
|
||||
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
- name: Test Windows auth migration and portable auth diagnostics
|
||||
run: go test -v -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
|
||||
|
||||
coverage-darwin:
|
||||
name: Coverage (macOS)
|
||||
@@ -208,10 +286,80 @@ jobs:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.txt
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
run: |
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Upload current coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
|
||||
- name: Upload supporting coverage profiles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: |
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -227,9 +375,6 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -247,18 +392,9 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run current and baseline unit tests with coverage
|
||||
- name: Run baseline unit tests with coverage
|
||||
run: |
|
||||
set -eu
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
@@ -274,7 +410,90 @@ jobs:
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: coverage-base.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
- coverage-current
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Verify coverage profile jobs
|
||||
env:
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
"baseline:$BASELINE_RESULT"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
env:
|
||||
|
||||
+1227
-158
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,46 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
|
||||
- **Protected existing-tag recovery** — `dws-release recover <version>` can resume a failed, unpublished annotated tag through the normal contract, build, Developer ID signing, immutable GitHub Release, Homebrew, npm, and OSS jobs. Recovery requires the exact tag object, peeled commit, failed tag-push run, typed version confirmation, and the protected `release-recovery` environment; successful runs are accepted as future beta/stable delivery evidence.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
|
||||
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
|
||||
|
||||
## [1.0.53-beta.4] - 2026-07-17
|
||||
|
||||
This beta validates the expanded personal IM event subscriptions and the flattened `event consume` structured output introduced after v1.0.53-beta.3.
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded personal IM event subscriptions** (#651) — adds one-to-one and group events for message read receipts, recalls, and reactions; publishes the specified-sender receive event; and lets one-to-one/sender subscriptions target either a staff `--user` or an `--open-dingtalk-id`. Event Schema now exposes these alternatives through machine-readable parameter constraints.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Personal event structured output is now flat** (#651) — `event consume` projects NDJSON/JSON/pretty/compact output into event-specific top-level DTOs, so consumers read fields such as `content`, `sender`, and `conversation_id` directly instead of parsing `.data | fromjson`. This is a breaking change for scripts using the former transport envelope; the original server payload remains available through `-f raw`, while `--debug-raw-events` preserves the full diagnostic envelope.
|
||||
|
||||
## [1.0.53-beta.3] - 2026-07-17
|
||||
|
||||
This beta validates multi-account profile support and the post-v1.0.53-beta.2 compatibility fixes for Windows portable authentication, IM shortcuts, and Aitable import uploads.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multiple accounts in one DingTalk organization** — profiles are keyed by `corpId:userId`, `--profile` accepts organization IDs/names plus user IDs/names, and organization-only selection uses its explicitly remembered current account or asks for an exact account when ambiguous.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Profile-scoped logout and consistent token storage** — `dws auth logout --profile` can remove one account or every account in an organization, while identity token slots remain the source of truth and legacy organization/global mirrors stay compatible without overwriting newer account credentials.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.
|
||||
- **IM shortcut message tags and compatibility aliases** (#646) — IM send shortcuts now add the same AI-sent marker as `chat message send` by default, support `--ai-tag=false` to opt out, and preserve compatible search, conversation-ID, and page-size aliases.
|
||||
- **Aitable import upload file-size validation** (#654) — `dws aitable import upload` and `dws aitable +import-upload` now require a positive `--file-size` and always send it to the upload-preparation API, preventing invalid requests without the actual file size.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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.53-beta.1"
|
||||
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.53-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7fef5add684189bfef16a1731fab4883fa096115dcd645c79bf9b3918758dbcb"
|
||||
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.53-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "8cde0fd62849dcb52bc053022361f983953bd5a4bac1edfc96076a84636debbe"
|
||||
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.53-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "17989f94b422d6490249c5e095049a9efc1c3861176fc249d5e7eea283f5e675"
|
||||
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.53-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "439d6a1ffc9572ed461b77a8bb27f6ddb4d4c9ce9d58474c0180955fc8a1d8d7"
|
||||
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.53-beta.1/dws-skills.zip"
|
||||
sha256 "a93ba8a73f2e319038a7bcb32038b9e7bd93d1e9af012a430ebf994498c140d4"
|
||||
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
|
||||
|
||||
@@ -45,7 +45,7 @@ rebuild:
|
||||
@./scripts/dev/build.sh
|
||||
|
||||
test:
|
||||
@./test/scripts/run_all_tests.sh
|
||||
@./test/scripts/run_all_tests.sh --timeout 5m
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -283,16 +283,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
|
||||
`dws` can stay logged in to several DingTalk accounts at once, including multiple accounts in the same organization. A profile is uniquely identified by `corpId:userId`; the current profile decides which identity a command runs as.
|
||||
|
||||
```bash
|
||||
dws auth login # log in to another org → adds a profile (first login becomes the primary)
|
||||
dws profile list # list logged-in orgs (primary / current marker, status)
|
||||
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
|
||||
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
|
||||
dws auth login # add or refresh one account
|
||||
dws profile list # list every logged-in account
|
||||
dws profile switch <corpId:userId> # persistently switch; use - to toggle back
|
||||
dws profile switch "<corpName>:<userName>" # friendly input; names must be unique
|
||||
dws --profile <corpId> contact user search --query "..." # use that org's explicitly recorded current account
|
||||
dws --profile <corpId:userId> contact user search --query "..." # use one exact account without changing the default
|
||||
```
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
Selectors support `corpId:userId`, `corpId:userName`, `corpName:userId`, and `corpName:userName`. Friendly names are input aliases only; use the stable `profile` value returned by `profile list` for automation. Duplicate organization or account names fail with explicit `corpId:userId` candidates. If an organization has multiple accounts but no recorded current account, `--profile <corpId>` fails instead of choosing the first or most recently used account.
|
||||
|
||||
`currentProfile`, `previousProfile`, and per-organization defaults are stored as exact identities. `primaryProfile` remains in JSON only for compatibility and is not used for selection. `profile list` reads status and expiry from each real identity Token without refreshing it. `auth logout --profile <corpId>` removes all local accounts in that organization; an exact selector or local profile name removes one account.
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list profiles, group by `corpId`, and use the unique `isOrgCurrent=true` account for each organization. If a multi-account organization has no default, ask the user to choose an account first. Writes default to the current account — confirm both organization and account before cross-org writes.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
@@ -302,7 +308,7 @@ env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --form
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected account with `dws auth logout --profile <corpId:userId>`, or all accounts in one organization with `--profile <corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -323,6 +329,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>
|
||||
|
||||
@@ -486,6 +495,9 @@ dws event consume user_im_message_receive_at -f ndjson
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
@@ -494,6 +506,8 @@ dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
For one-to-one and specified-sender events, use exactly one target identity: `--user` for an internal `userId`, or `--open-dingtalk-id` for an `openDingtalkId`. The CLI does not infer or convert between these identity types.
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
|
||||
+18
-7
@@ -280,16 +280,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
|
||||
`dws` 可以同时登录多个钉钉账号,同一组织也能保留多个账号。一个 profile 由 `corpId + userId` 唯一确定。
|
||||
|
||||
```bash
|
||||
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
|
||||
dws profile list # 列出已登录组织(主 / 当前标记、状态)
|
||||
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
|
||||
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
|
||||
dws auth login # 新增或刷新一个账号
|
||||
dws profile list # 列出全部账号,profile 字段是稳定的 corpId:userId
|
||||
dws profile switch <corpId:userId> # 持久切换账号;用 - 切回上一个
|
||||
dws profile switch "组织名:用户名" # 名称输入要求唯一
|
||||
dws --profile <corpId> contact user search --query "..." # 使用该组织明确记录的当前账号
|
||||
dws --profile <corpId:userId> contact user search --query "..." # 单次精确指定账号,不改默认账号
|
||||
```
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`。名称只用于输入,自动化应使用 `profile list` 返回的稳定 `profile`。组织名或用户名重名时会列出候选并报错;同组织多账号但没有明确当前账号时,只传组织也会报错,不会选择第一项或最近使用账号。
|
||||
|
||||
`currentProfile`、`previousProfile` 和组织默认账号都保存精确身份。`primaryProfile` 只为 JSON 兼容保留,不再参与选择。`profile list` 直接读取各身份 Token 计算状态和到期时间,不触发刷新。`auth logout --profile <corpId>` 退出该组织全部账号;精确选择器或本地 profile 名只退出一个账号。
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list`,每个组织使用唯一的 `isOrgCurrent=true` 账号;若多账号组织没有默认账号,先让用户指定账号。写操作默认只在当前账号执行——跨组织写之前先确认目标组织和账号。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
@@ -299,7 +305,7 @@ env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --form
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,优先使用 `dws auth logout --profile <corpId:userId>` 只清理受影响账号;只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -483,6 +489,9 @@ dws event consume user_im_message_receive_at -f ndjson
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
@@ -491,6 +500,8 @@ dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
|
||||
+34
-6
@@ -65,21 +65,49 @@ git diff --check
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
|
||||
allow fine-grained personal access tokens to target this repository, so use a
|
||||
classic personal access token owned by a maintainer or release-bot account with
|
||||
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
|
||||
maintainer or release-bot account, limited to this repository with
|
||||
`Contents: write` and `Pull requests: write`. If organization policy prevents
|
||||
that account from targeting the repository, use a dedicated classic token with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
|
||||
secret. Replace it immediately if it is exposed, its owner loses repository
|
||||
access, or the release-bot ownership changes. The Release workflow uses this
|
||||
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
|
||||
and rotate it before its configured expiration. Replace it immediately if it is
|
||||
exposed, its owner loses repository access, or the release-bot ownership
|
||||
changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
The default-branch governance preflight and every tag contract authenticate the
|
||||
token before publication, reject over-scoped classic tokens, confirm its
|
||||
identity, and run a controlled write canary. The canary pushes a unique
|
||||
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
|
||||
draft PR, closes it, and deletes the branch with the same token. This proves both
|
||||
Contents and Pull requests write access before publication without merging
|
||||
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only
|
||||
repository `Administration: read`. The immutable-releases REST endpoint is an
|
||||
administration setting and cannot be read by the workflow's built-in
|
||||
`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag
|
||||
contract use this same credential so a missing or expired identity is detected
|
||||
before an irreversible tag is created.
|
||||
|
||||
Create a protected `release-recovery` environment limited to protected
|
||||
branches, with a required reviewer, self-review disabled, and administrator
|
||||
bypass disabled. The workflow reads the environment through the GitHub API and
|
||||
fails closed unless the required-reviewer, prevent-self-review, and protected-
|
||||
branch rules are present.
|
||||
Recovery is restricted to an existing annotated tag whose exact tag object,
|
||||
commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
Defines the stable `dws event consume` subprocess contract so an
|
||||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||||
and machine-read why it exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
@@ -16,7 +14,6 @@ tracked separately and intentionally out of scope here.
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
@@ -28,7 +25,7 @@ tracked separately and intentionally out of scope here.
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
@@ -75,7 +72,7 @@ or runtime failure (permissions, network, params) = non-zero, with no
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
Ownership-based cleanup:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
|
||||
+24
-3
@@ -45,13 +45,13 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -99,15 +99,36 @@ npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_versio
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
## 既有 tag 的紧急恢复
|
||||
|
||||
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
|
||||
|
||||
```bash
|
||||
dws-release recover v1.2.3-beta.1
|
||||
```
|
||||
|
||||
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
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 平台配置两项不可由脚本替代的规则:
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -56,10 +56,19 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
return "", tokenErr
|
||||
}
|
||||
if strings.TrimSpace(authpkg.RuntimeProfile()) != "" {
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
manager := newLegacyTokenManager(configDir)
|
||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
||||
return strings.TrimSpace(leg), nil
|
||||
}
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,15 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestResolveAuxiliaryAccessToken_explicitToken(t *testing.T) {
|
||||
@@ -24,3 +32,48 @@ func TestResolveAuxiliaryAccessToken_emptyConfigDir(t *testing.T) {
|
||||
t.Fatal("expected error for empty config directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccessTokenFromDirPreservesRefreshFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
|
||||
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "expired-access",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp_refresh",
|
||||
UserID: "user_refresh",
|
||||
ClientID: "client_refresh",
|
||||
Source: "mcp",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = refreshFailureRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("refresh endpoint rejected token")
|
||||
})
|
||||
|
||||
token, err := resolveAccessTokenFromDir(context.Background(), configDir)
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("resolveAccessTokenFromDir() error = nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refresh endpoint rejected token") {
|
||||
t.Fatalf("error = %q, want original refresh failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
type refreshFailureRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f refreshFailureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
@@ -217,6 +217,12 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
||||
t.Fatalf("legacy token = %q, %v", got, err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" {
|
||||
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} }
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} }
|
||||
edition.Override(&edition.Hooks{})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -117,9 +118,29 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
valid, brokenAt, err := auditVerify(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验失败: %w", err)
|
||||
valid, brokenAt, verifyErr := auditVerify(target)
|
||||
if output.ResolveFormat(cmd, output.FormatTable) == output.FormatJSON {
|
||||
if verifyErr != nil && brokenAt == 0 {
|
||||
return fmt.Errorf("校验失败: %w", verifyErr)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"valid": valid,
|
||||
"file": target,
|
||||
"brokenAt": brokenAt,
|
||||
}
|
||||
if verifyErr != nil {
|
||||
payload["reason"] = verifyErr.Error()
|
||||
}
|
||||
if err := output.WriteCommandPayload(cmd, payload, output.FormatTable); err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
auditExit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("校验失败: %w", verifyErr)
|
||||
}
|
||||
if valid {
|
||||
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
|
||||
|
||||
@@ -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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeAuditVerifyJSON(t *testing.T, verify func(string) (bool, int, error)) (map[string]any, int, error) {
|
||||
t.Helper()
|
||||
previousVerify, previousExit := auditVerify, auditExit
|
||||
t.Cleanup(func() {
|
||||
auditVerify, auditExit = previousVerify, previousExit
|
||||
})
|
||||
auditVerify = verify
|
||||
exitCode := 0
|
||||
auditExit = func(code int) { exitCode = code }
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
root.AddCommand(newAuditVerifyCommand())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs([]string{"verify", "--file", "/tmp/audit.jsonl"})
|
||||
err := root.Execute()
|
||||
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("audit verify stdout must be one JSON document: %v\n%s", decodeErr, stdout.String())
|
||||
}
|
||||
return payload, exitCode, err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuditVerifyJSONOutputIsSingleDocument(t *testing.T) {
|
||||
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
|
||||
return true, 0, nil
|
||||
})
|
||||
if err != nil || exitCode != 0 {
|
||||
t.Fatalf("audit verify returned err=%v exit=%d", err, exitCode)
|
||||
}
|
||||
if payload["valid"] != true || payload["file"] != "/tmp/audit.jsonl" || payload["brokenAt"] != float64(0) {
|
||||
t.Fatalf("unexpected audit payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuditVerifyBrokenJSONIncludesReasonBeforeExit(t *testing.T) {
|
||||
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
|
||||
return false, 3, errors.New("prev_hash mismatch")
|
||||
})
|
||||
if err != nil || exitCode != 1 {
|
||||
t.Fatalf("broken audit verify returned err=%v exit=%d", err, exitCode)
|
||||
}
|
||||
if payload["valid"] != false || payload["brokenAt"] != float64(3) || payload["reason"] != "prev_hash mismatch" {
|
||||
t.Fatalf("unexpected broken audit payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
+182
-35
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -85,7 +86,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
newAuthExchangeCommand(patCaller),
|
||||
newAuthResetCommand(),
|
||||
)
|
||||
return cmd
|
||||
@@ -146,6 +147,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
|
||||
}
|
||||
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||
@@ -158,6 +162,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
|
||||
}
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||
if err != nil {
|
||||
@@ -167,17 +174,13 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
|
||||
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
|
||||
defer restoreProfile()
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
if postLoginTUIMode {
|
||||
@@ -303,7 +306,10 @@ var (
|
||||
authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions
|
||||
authRunDirectPATWait = runDirectPATAuthCheckWaitOnly
|
||||
authResolveProfile = authpkg.ResolveProfile
|
||||
authResolveProfileDeletion = authpkg.ResolveProfileDeletionScope
|
||||
authRevokeToken = authpkg.RevokeTokenRemote
|
||||
authRevokeTokenForData = authpkg.RevokeTokenRemoteForData
|
||||
authLoadTokenForProfile = authpkg.LoadTokenDataForProfile
|
||||
authDeleteProfileToken = authpkg.DeleteTokenDataForProfile
|
||||
authEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||
authLoadProfiles = authpkg.LoadProfiles
|
||||
@@ -423,9 +429,10 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
Short: "清除认证信息(默认退出所有组织)",
|
||||
Long: `清除本机钉钉登录态。
|
||||
|
||||
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
|
||||
默认退出全部账号。--profile 传组织时退出该组织全部账号;传精确账号或本地 profile 名时只退出一个账号。`,
|
||||
Example: ` dws auth logout
|
||||
dws auth logout --profile <corpId>
|
||||
dws auth logout --profile <corpId>:<userId>
|
||||
dws auth logout --profile "钉钉"`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -455,7 +462,7 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
|
||||
cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -468,7 +475,8 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
|
||||
Example: ` dws auth status
|
||||
dws auth status --profile <corpId>
|
||||
dws auth status --profile "钉钉"
|
||||
dws auth status --profile <corpId>:<userId>
|
||||
dws auth status --profile "钉钉:孙博文"
|
||||
dws auth status --profile <corpId> --format json`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -477,6 +485,17 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
profileSelector = strings.TrimSpace(profileSelector)
|
||||
if profileSelector != "" {
|
||||
selected, resolveErr := authpkg.ResolveProfile(configDir, profileSelector)
|
||||
if resolveErr != nil {
|
||||
return apperrors.NewValidation(resolveErr.Error())
|
||||
}
|
||||
if selected == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
|
||||
}
|
||||
profileSelector = authpkg.ProfileSelector(*selected)
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
|
||||
@@ -484,6 +503,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
refreshed := false
|
||||
var tokenData *authpkg.TokenData
|
||||
var statusErr error
|
||||
var refreshFailure error
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := authOAuthStatus(provider); err == nil {
|
||||
@@ -498,18 +518,23 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
refreshed = true
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
refreshFailure = refreshErr
|
||||
_ = authDeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
_ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
refreshFailure = refreshErr
|
||||
_ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
|
||||
authenticated = true
|
||||
}
|
||||
} else {
|
||||
statusErr = err
|
||||
}
|
||||
diagnostic := authStatusDiagnosticFromError(statusErr)
|
||||
if refreshFailure != nil {
|
||||
diagnostic = authStatusRefreshDiagnostic(refreshFailure)
|
||||
}
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
@@ -554,7 +579,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
|
||||
cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -618,13 +643,33 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authResolveProfile(configDir, selector); err != nil {
|
||||
selected, exact, err := authResolveProfileDeletion(configDir, selector)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(selector)
|
||||
defer restoreProfile()
|
||||
_ = authRevokeToken(ctx)
|
||||
if err := authDeleteProfileToken(configDir, selector); err != nil {
|
||||
if selected == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
stableSelector := selected.CorpID
|
||||
if exact {
|
||||
stableSelector = authpkg.ProfileSelector(*selected)
|
||||
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
} else if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID != selected.CorpID {
|
||||
continue
|
||||
}
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := authDeleteProfileToken(configDir, stableSelector); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
@@ -642,9 +687,9 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
|
||||
_ = authRevokeToken(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||
_ = authRevokeToken(ctx)
|
||||
restoreProfile()
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := authDeleteAllTokenData(configDir); err != nil {
|
||||
@@ -665,7 +710,19 @@ func pushRuntimeProfile(selector string) func() {
|
||||
}
|
||||
}
|
||||
|
||||
func replaceRuntimeProfile(selector string) func() {
|
||||
previous := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile(strings.TrimSpace(selector))
|
||||
return func() {
|
||||
authpkg.SetRuntimeProfile(previous)
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthExportCommand() *cobra.Command {
|
||||
return newAuthExportCommandWithSupport(authpkg.PortableExportSupportError)
|
||||
}
|
||||
|
||||
func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出可迁移认证包",
|
||||
@@ -690,6 +747,9 @@ func newAuthExportCommand() *cobra.Command {
|
||||
if !asBase64 && output == "" {
|
||||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||||
}
|
||||
if err := supportError(); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if !authPortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
@@ -733,6 +793,10 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newAuthImportCommand() *cobra.Command {
|
||||
return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError)
|
||||
}
|
||||
|
||||
func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "导入可迁移认证包",
|
||||
@@ -761,7 +825,9 @@ func newAuthImportCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
|
||||
if err := supportError(); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
if !force && authPortableTargetPopulated(configDir) {
|
||||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||||
@@ -797,7 +863,7 @@ func newAuthImportCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthExchangeCommand() *cobra.Command {
|
||||
func newAuthExchangeCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exchange",
|
||||
Short: "Exchange an authorization code for credentials",
|
||||
@@ -819,6 +885,9 @@ func newAuthExchangeCommand() *cobra.Command {
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, caller, data)
|
||||
}
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
||||
defer cancel()
|
||||
@@ -1141,6 +1210,19 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
flow := "oauth"
|
||||
if strings.TrimSpace(token) != "" {
|
||||
flow = "token"
|
||||
} else if device {
|
||||
flow = "device"
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.request",
|
||||
"flow", flow,
|
||||
"profile_selector", strings.TrimSpace(profileSelector),
|
||||
"target_corp_id", targetCorpID,
|
||||
"recommend", recommend,
|
||||
)
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
@@ -1176,7 +1258,11 @@ type contactProfileIdentity struct {
|
||||
UserName string
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
type tokenOverrideToolCaller interface {
|
||||
CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error)
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
if caller == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1184,24 +1270,62 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.start",
|
||||
"corp_id", corpID,
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"corp_name", strings.TrimSpace(data.CorpName),
|
||||
)
|
||||
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.result",
|
||||
"source", "token_exchange",
|
||||
"corp_id", corpID,
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"corp_name", strings.TrimSpace(data.CorpName),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
restoreProfile := pushRuntimeProfile(corpID)
|
||||
defer restoreProfile()
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
|
||||
"profile": corpID,
|
||||
})
|
||||
var (
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
)
|
||||
if tokenCaller, ok := caller.(tokenOverrideToolCaller); ok && strings.TrimSpace(data.AccessToken) != "" {
|
||||
result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil)
|
||||
} else {
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
return fmt.Errorf("login identity lookup requires an in-memory token override")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.error",
|
||||
"corp_id", corpID,
|
||||
"existing_user_id", strings.TrimSpace(data.UserID),
|
||||
"error", err,
|
||||
)
|
||||
if strings.TrimSpace(data.UserID) != "" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
if !ok {
|
||||
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
|
||||
return nil
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.result",
|
||||
"source", "contact.get_current_user_profile",
|
||||
"corp_id", strings.TrimSpace(identity.CorpID),
|
||||
"user_id", strings.TrimSpace(identity.UserID),
|
||||
"user_name", strings.TrimSpace(identity.UserName),
|
||||
"corp_name", strings.TrimSpace(identity.CorpName),
|
||||
)
|
||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||||
}
|
||||
@@ -1217,12 +1341,23 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
"corp_id", corpID,
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"changed", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if err := authSaveTokenData(configDir, &updated); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = updated
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"changed", true,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1249,6 +1384,7 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
|
||||
OrgName string `json:"orgName"`
|
||||
UserID string `json:"userId"`
|
||||
UserIDLower string `json:"userid"`
|
||||
OrgUserID string `json:"orgUserId"`
|
||||
OrgUserName string `json:"orgUserName"`
|
||||
Name string `json:"name"`
|
||||
} `json:"orgEmployeeModel"`
|
||||
@@ -1264,7 +1400,7 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
@@ -1351,6 +1487,17 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
func authStatusRefreshDiagnostic(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "token_refresh_failed",
|
||||
Message: fmt.Sprintf("Token 刷新失败: %v", err),
|
||||
Hint: "请重新运行 dws auth login 完成授权。",
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
|
||||
resp := authStatusResponse{
|
||||
Success: true,
|
||||
|
||||
@@ -28,6 +28,9 @@ type authCoverageCaller struct {
|
||||
func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result, c.err
|
||||
}
|
||||
func (c *authCoverageCaller) CallToolWithToken(ctx context.Context, _ string, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
func (*authCoverageCaller) Format() string { return "json" }
|
||||
func (*authCoverageCaller) DryRun() bool { return false }
|
||||
func (*authCoverageCaller) Fields() string { return "" }
|
||||
@@ -67,12 +70,16 @@ func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldClientID := authpkg.ClientID()
|
||||
oldClientSecret := authpkg.ClientSecret()
|
||||
oldRunForm := authRunForm
|
||||
oldPrompt := authLoginManualCredentialsPrompt
|
||||
oldSaveConfig := authSaveAppConfig
|
||||
oldResolve := authResolveProfile
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authpkg.SetClientID(oldClientID)
|
||||
authpkg.SetClientSecret(oldClientSecret)
|
||||
authRunForm = oldRunForm
|
||||
authLoginManualCredentialsPrompt = oldPrompt
|
||||
authSaveAppConfig = oldSaveConfig
|
||||
@@ -352,14 +359,22 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||
t.Fatal("caller error should propagate")
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(
|
||||
ctx,
|
||||
"cfg",
|
||||
&authCoverageCaller{err: errors.New("call")},
|
||||
&authpkg.TokenData{CorpID: "ding", UserID: "known", AccessToken: "token"},
|
||||
); err != nil {
|
||||
t.Fatalf("optional contact metadata failure with known userId = %v", err)
|
||||
}
|
||||
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 {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); 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 {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); 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"}}]}`}}}}
|
||||
@@ -371,11 +386,7 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
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"}
|
||||
data := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||
t.Fatalf("enriched = %#v, %v", data, err)
|
||||
}
|
||||
@@ -394,6 +405,7 @@ func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||
device := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
_, _ = authDeviceLogin(device, ctx)
|
||||
oauth := authpkg.NewOAuthProvider(configDir, nil)
|
||||
oauth.NoBrowser = true
|
||||
_, _ = authOAuthStatus(oauth)
|
||||
_, _ = authOAuthAccessToken(oauth, ctx)
|
||||
_, _ = authOAuthLogin(oauth, ctx, true)
|
||||
@@ -409,7 +421,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
|
||||
oldDelete := authDeleteTokenData
|
||||
oldMark := authMarkProfileStatus
|
||||
oldResolve := authResolveProfile
|
||||
oldResolveDeletion := authResolveProfileDeletion
|
||||
oldRevoke := authRevokeToken
|
||||
oldRevokeForData := authRevokeTokenForData
|
||||
oldLoadTokenForProfile := authLoadTokenForProfile
|
||||
oldDeleteProfile := authDeleteProfileToken
|
||||
oldMigrate := authEnsureProfilesMigration
|
||||
oldLoadProfiles := authLoadProfiles
|
||||
@@ -421,7 +436,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
|
||||
authDeleteTokenData = oldDelete
|
||||
authMarkProfileStatus = oldMark
|
||||
authResolveProfile = oldResolve
|
||||
authResolveProfileDeletion = oldResolveDeletion
|
||||
authRevokeToken = oldRevoke
|
||||
authRevokeTokenForData = oldRevokeForData
|
||||
authLoadTokenForProfile = oldLoadTokenForProfile
|
||||
authDeleteProfileToken = oldDeleteProfile
|
||||
authEnsureProfilesMigration = oldMigrate
|
||||
authLoadProfiles = oldLoadProfiles
|
||||
@@ -510,20 +528,51 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
|
||||
t.Fatalf("mark-expired = %v, marked=%v", err, marked)
|
||||
}
|
||||
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) { return nil, false, 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") }
|
||||
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
|
||||
return &authpkg.Profile{CorpID: "ding", UserID: "user"}, true, nil
|
||||
}
|
||||
authLoadTokenForProfile = func(string, string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{CorpID: "ding", UserID: "user"}, nil
|
||||
}
|
||||
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return errors.New("ignored") }
|
||||
var deletedSelector string
|
||||
authDeleteProfileToken = func(_ string, selector string) error {
|
||||
deletedSelector = selector
|
||||
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 deletedSelector != "ding:user" {
|
||||
t.Fatalf("exact deletion selector = %q, want stable identity selector", deletedSelector)
|
||||
}
|
||||
authDeleteProfileToken = func(_ string, selector string) error {
|
||||
deletedSelector = selector
|
||||
return nil
|
||||
}
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedSelector != "ding:user" {
|
||||
t.Fatalf("exact deletion selector = %q, want stable identity selector", deletedSelector)
|
||||
}
|
||||
|
||||
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
|
||||
return &authpkg.Profile{CorpID: "ding", UserID: "user"}, false, nil
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding", UserID: "user"}}}, nil
|
||||
}
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", "organization-name"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedSelector != "ding" {
|
||||
t.Fatalf("organization deletion selector = %q, want stable corpId", deletedSelector)
|
||||
}
|
||||
|
||||
authEnsureProfilesMigration = func(string) error { return errors.New("migrate") }
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||
@@ -536,6 +585,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
|
||||
}
|
||||
revokes := 0
|
||||
authRevokeToken = func(context.Context) error { revokes++; return nil }
|
||||
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { revokes++; return nil }
|
||||
authLoadTokenForProfile = func(string, string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "token"}, 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 {
|
||||
@@ -618,7 +671,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
authDeleteAppConfig = oldDeleteConfig
|
||||
})
|
||||
|
||||
export := newAuthExportCommand()
|
||||
export := newAuthExportCommandWithSupport(func() error { return nil })
|
||||
badString := &cobra.Command{}
|
||||
badString.Flags().Bool("output", false, "")
|
||||
if err := export.RunE(badString, nil); err == nil {
|
||||
@@ -658,7 +711,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
if err := export.RunE(export, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
export = newAuthExportCommand()
|
||||
export = newAuthExportCommandWithSupport(func() error { return nil })
|
||||
_, out, _ := authCoverageRoot(export, "table", false)
|
||||
_ = export.Flags().Set("base64", "true")
|
||||
export.SetOut(&appFailWriter{err: errors.New("stdout")})
|
||||
@@ -676,7 +729,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommand()
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
badInput := &cobra.Command{}
|
||||
badInput.Flags().Bool("input", false, "")
|
||||
if err := importCmd.RunE(badInput, nil); err == nil {
|
||||
@@ -734,7 +787,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exchange := newAuthExchangeCommand()
|
||||
exchange := newAuthExchangeCommand(nil)
|
||||
badCode := &cobra.Command{}
|
||||
badCode.Flags().Bool("code", false, "")
|
||||
if err := exchange.RunE(badCode, nil); err == nil {
|
||||
|
||||
@@ -20,9 +20,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -58,7 +60,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
|
||||
exportCmd := newAuthExportCommand()
|
||||
exportCmd := newAuthExportCommandWithSupport(func() error { return nil })
|
||||
var exported bytes.Buffer
|
||||
exportCmd.SetOut(&exported)
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
@@ -84,7 +86,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
return authpkg.PortableImportReport{}, err
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommand()
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", inputPath, "--base64"})
|
||||
@@ -96,6 +98,260 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(t *testing.T) {
|
||||
exportCmd := newAuthExportCommandWithSupport(func() error {
|
||||
return errors.New("portable auth export is unavailable for the test backend")
|
||||
})
|
||||
exportCmd.SetOut(&bytes.Buffer{})
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"--base64"})
|
||||
|
||||
err := exportCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth export should reject an unsupported credential backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "test backend") {
|
||||
t.Fatalf("error = %v, want backend-specific reason", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
t.Cleanup(CloseFileLogger)
|
||||
|
||||
exportCmd := NewRootCommand()
|
||||
exportCmd.SetOut(&bytes.Buffer{})
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
|
||||
|
||||
err := exportCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth export should reject the Windows DPAPI backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
keychainDir := filepath.Join(root, "keychain")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error {
|
||||
return errors.New("portable auth import is unavailable for the test backend")
|
||||
})
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", filepath.Join(root, "missing-bundle.tar.gz")})
|
||||
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject an unsupported credential backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "test backend") {
|
||||
t.Fatalf("error = %v, want backend-specific reason", err)
|
||||
}
|
||||
for _, path := range []string{configDir, keychainDir} {
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("unsupported import touched %s: stat error = %v", path, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
// NewRootCommand initializes the normal CLI file logger below configDir.
|
||||
// Register its cleanup after TempDir so the Windows handle is closed before
|
||||
// testing removes the temporary directory.
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
keychainDir := filepath.Join(root, "keychain")
|
||||
inputPath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(inputPath, []byte("the capability guard must run before this input is read"), 0o600); err != nil {
|
||||
t.Fatalf("write input sentinel error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath})
|
||||
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject the Windows DPAPI backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, want)
|
||||
}
|
||||
}
|
||||
// The root command may create configDir/logs as part of normal CLI startup.
|
||||
// The capability guard must still run before any auth state is imported.
|
||||
for _, path := range []string{
|
||||
keychainDir,
|
||||
authpkg.ProfilesPath(configDir),
|
||||
filepath.Join(configDir, "app.json"),
|
||||
filepath.Join(configDir, "token.json"),
|
||||
} {
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackendWithPopulatedCredentialBeforeRead(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
|
||||
previous, previousErr := authpkg.LoadTokenDataKeychain()
|
||||
if previousErr != nil && !errors.Is(previousErr, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("capture existing Windows credential: %v", previousErr)
|
||||
}
|
||||
hadPrevious := previousErr == nil
|
||||
t.Cleanup(func() {
|
||||
if hadPrevious {
|
||||
_ = authpkg.SaveTokenDataKeychain(previous)
|
||||
} else {
|
||||
_ = authpkg.DeleteTokenDataKeychain()
|
||||
}
|
||||
})
|
||||
|
||||
want := &authpkg.TokenData{
|
||||
AccessToken: "windows-existing-access",
|
||||
RefreshToken: "windows-existing-refresh",
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "windows-existing-corp",
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychain(want); err != nil {
|
||||
t.Fatalf("seed cleanup-scoped Windows DPAPI credential: %v", err)
|
||||
}
|
||||
|
||||
originalTarget := authPortableTargetPopulated
|
||||
originalRead := authReadFile
|
||||
targetChecks := 0
|
||||
bundleReads := 0
|
||||
authPortableTargetPopulated = func(configDir string) bool {
|
||||
targetChecks++
|
||||
return originalTarget(configDir)
|
||||
}
|
||||
authReadFile = func(path string) ([]byte, error) {
|
||||
bundleReads++
|
||||
return originalRead(path)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
authPortableTargetPopulated = originalTarget
|
||||
authReadFile = originalRead
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
inputPath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(inputPath, []byte("unsupported Windows import must not read this bundle"), 0o600); err != nil {
|
||||
t.Fatalf("write bundle sentinel: %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(root, ".dws"))
|
||||
|
||||
importCmd := newAuthImportCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", inputPath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject a populated Windows DPAPI backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
for _, required := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), required) {
|
||||
t.Fatalf("error = %v, want substring %q", err, required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("unsupported Windows import suggested impossible --force remediation: %v", err)
|
||||
}
|
||||
if targetChecks != 0 || bundleReads != 0 {
|
||||
t.Fatalf("unsupported Windows import inspected credentials/bundle: target_checks=%d bundle_reads=%d", targetChecks, bundleReads)
|
||||
}
|
||||
|
||||
got, err := authpkg.LoadTokenDataKeychain()
|
||||
if err != nil {
|
||||
t.Fatalf("reload Windows DPAPI credential after rejection: %v", err)
|
||||
}
|
||||
if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || got.CorpID != want.CorpID {
|
||||
t.Fatalf("Windows auth state changed after rejected import: got=%#v want=%#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "existing",
|
||||
RefreshToken: "existing-refresh",
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
|
||||
t.Fatalf("write bundle stub error = %v", err)
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
var stderr bytes.Buffer
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&stderr)
|
||||
importCmd.SetArgs([]string{"--input", bundlePath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import without --force should fail when auth exists")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("error = %v, want --force hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
originalTarget := authPortableTargetPopulated
|
||||
authPortableTargetPopulated = func(string) bool { return true }
|
||||
@@ -110,10 +366,9 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
t.Fatalf("write bundle stub error = %v", err)
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommand()
|
||||
var stderr bytes.Buffer
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&stderr)
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", bundlePath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
@@ -244,7 +499,7 @@ func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
func TestAuthStatusRefreshFailureReportsUnauthenticatedDiagnostic(t *testing.T) {
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
@@ -263,6 +518,9 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "dingcorp",
|
||||
UserID: "user-dingcorp",
|
||||
ClientID: "client-dingcorp",
|
||||
Source: "mcp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
@@ -280,7 +538,7 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "status"})
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
@@ -291,8 +549,18 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
t.Fatal("secure token data should remain in keychain after refresh failure")
|
||||
}
|
||||
|
||||
if !bytes.Contains(out.Bytes(), []byte("\"authenticated\"")) {
|
||||
t.Fatalf("output should still report authenticated status:\n%s", out.String())
|
||||
var resp authStatusResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true after refresh failure: %+v", resp)
|
||||
}
|
||||
if resp.Reason != "token_refresh_failed" {
|
||||
t.Fatalf("reason = %q, want token_refresh_failed: %+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "refresh failed") {
|
||||
t.Fatalf("message = %q, want original refresh failure", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,8 +608,35 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
||||
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged exact secondary identity", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRejectsAmbiguousProfileSelector(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_first")
|
||||
first.CorpName = "Shared Org"
|
||||
second := authLogoutTestToken("corp_second")
|
||||
second.CorpName = "Shared Org"
|
||||
setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status", "--profile", "Shared Org"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("auth status accepted ambiguous profile selector\noutput:\n%s", out.String())
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %T %v, want validation error", err, err)
|
||||
}
|
||||
for _, candidate := range []string{"corp_first", "corp_second"} {
|
||||
if !strings.Contains(err.Error(), candidate) {
|
||||
t.Fatalf("error = %q, want candidate %q", err.Error(), candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,8 +810,8 @@ func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
|
||||
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
|
||||
@@ -536,6 +831,102 @@ func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutExactProfilePreservesSameCorpAccount(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_same")
|
||||
first.UserID = "user_1"
|
||||
second := authLogoutTestToken("corp_same")
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
configDir := setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_same:user_2"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout exact profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != "user_1" {
|
||||
t.Fatalf("profiles = %#v, want only user_1 retained", cfg.Profiles)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForIdentity("corp_same", "user_2") {
|
||||
t.Fatal("selected identity token should be deleted")
|
||||
}
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "corp_same")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(org) error = %v", err)
|
||||
}
|
||||
if loaded.UserID != "user_1" || loaded.AccessToken != first.AccessToken {
|
||||
t.Fatalf("org current token = %#v, want retained user_1", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutLocalProfileNameRevokesOnlySelectedAccount(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_same")
|
||||
first.UserID = "user_1"
|
||||
first.UserName = "账号一"
|
||||
second := authLogoutTestToken("corp_same")
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
second.UserName = "账号二"
|
||||
configDir := setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
var selector string
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.UserID == "user_2" {
|
||||
selector = profile.Name
|
||||
}
|
||||
}
|
||||
if selector == "" || selector == second.CorpName {
|
||||
t.Fatalf("second local profile name = %q, want unique non-org alias", selector)
|
||||
}
|
||||
|
||||
requests := 0
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}, nil
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", selector})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout local profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("remote revoke requests = %d, want 1", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
@@ -621,8 +1012,15 @@ func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
t.Setenv("DWS_DEBUG_AUTH", "1")
|
||||
var logs bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
@@ -647,6 +1045,11 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
if !cfg.Yes {
|
||||
t.Fatal("Yes = false, want true")
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, `"msg":"auth.login.request"`) ||
|
||||
!strings.Contains(got, `"profile_selector":""`) ||
|
||||
!strings.Contains(got, `"target_corp_id":""`) {
|
||||
t.Fatalf("login request diagnostic log missing selector resolution:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
@@ -697,6 +1100,12 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}}
|
||||
authpkg.SetRuntimeProfile("corp_old:user_old")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
var authorizationProfiles []string
|
||||
fake.beforeCall = func(string) {
|
||||
authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile())
|
||||
}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -715,6 +1124,61 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("--recommend plan recommend = %#v, want true", got)
|
||||
}
|
||||
for _, profile := range authorizationProfiles {
|
||||
if profile != "" {
|
||||
t.Fatalf("manual token post-login profile = %q, want empty runtime selector", profile)
|
||||
}
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_old:user_old" {
|
||||
t.Fatalf("runtime profile after authorization = %q, want restored selector", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendUsesNewExactIdentity(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldOAuthLogin := authOAuthLogin
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authOAuthLogin = oldOAuthLogin
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
authpkg.SetRuntimeProfile("")
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "new-token",
|
||||
CorpID: "corp_same",
|
||||
UserID: "user_new",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[],"selectedScopes":[]}}`,
|
||||
}}
|
||||
var authorizationProfiles []string
|
||||
fake.beforeCall = func(string) {
|
||||
authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile())
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp_same:user_old")
|
||||
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--recommend"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login --recommend error = %v", err)
|
||||
}
|
||||
for _, profile := range authorizationProfiles {
|
||||
if profile != "corp_same:user_new" {
|
||||
t.Fatalf("post-login authorization profile = %q, want new exact identity", profile)
|
||||
}
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_same:user_old" {
|
||||
t.Fatalf("runtime profile after authorization = %q, want restored old identity", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
|
||||
@@ -939,7 +1403,7 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
func TestEnrichAuthLoginProfileFromContactBeforePersist(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
@@ -954,12 +1418,8 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
ClientID: "client-id",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
`{"success":true,"result":[{"isAdmin":false,"orgEmployeeModel":{"jobNumber":"202397","orgId":null,"orgName":"钉钉(中国)信息技术有限公司","orgUserId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
}}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
@@ -970,6 +1430,16 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
|
||||
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() before persist error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 0 {
|
||||
t.Fatalf("identity enrichment persisted token early: %#v", cfg.Profiles)
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
|
||||
if err != nil {
|
||||
@@ -981,8 +1451,50 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
||||
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
|
||||
if len(fake.args[0]) != 0 {
|
||||
t.Fatalf("contact profile args = %#v, want no arguments", fake.args[0])
|
||||
}
|
||||
if len(fake.tokens) != 1 || fake.tokens[0] != "access-token" {
|
||||
t.Fatalf("token overrides = %v, want access-token", fake.tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileLogsIdentityResolutionWithoutCredentials(t *testing.T) {
|
||||
t.Setenv("DWS_DEBUG_AUTH", "1")
|
||||
var logs bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
||||
|
||||
token := &authpkg.TokenData{
|
||||
AccessToken: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
CorpID: "ding_same_corp",
|
||||
}
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding_same_corp","orgName":"同一组织","userId":"user_two","orgUserName":"账号二"}}]}`,
|
||||
}}
|
||||
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), t.TempDir(), fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
}
|
||||
|
||||
got := logs.String()
|
||||
for _, want := range []string{
|
||||
`"msg":"auth.login.identity.lookup.start"`,
|
||||
`"msg":"auth.login.identity.lookup.result"`,
|
||||
`"corp_id":"ding_same_corp"`,
|
||||
`"user_id":"user_two"`,
|
||||
`"user_name":"账号二"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("diagnostic logs missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"secret-access-token", "secret-refresh-token"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("diagnostic logs exposed credential %q:\n%s", secret, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,6 +1508,7 @@ type authLoginRecommendSequenceCaller struct {
|
||||
responses []string
|
||||
tools []string
|
||||
args []map[string]any
|
||||
tokens []string
|
||||
beforeCall func(toolName string)
|
||||
}
|
||||
|
||||
@@ -1017,6 +1530,11 @@ func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string,
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.tokens = append(f.tokens, token)
|
||||
return f.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
@@ -74,6 +74,19 @@ func TestCrossPlatformCoverageToolCallerAdapterCoverage(t *testing.T) {
|
||||
t.Fatal("adapter flags were not forwarded")
|
||||
}
|
||||
adapter.flags.DryRun = false
|
||||
if _, err := (*toolCallerAdapter)(nil).CallToolWithToken(context.Background(), "token", "doc", "get", nil); err == nil {
|
||||
t.Fatal("nil adapter token override succeeded")
|
||||
}
|
||||
authpkg.SetRuntimeProfile("saved-profile")
|
||||
adapter.flags.Token = "saved-token"
|
||||
runner.result = executor.Result{Response: map[string]any{"content": []any{map[string]any{"type": "text", "text": "ok"}}}}
|
||||
if _, err := adapter.CallToolWithToken(context.Background(), "temporary-token", "doc", "get", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adapter.flags.Token != "saved-token" || authpkg.RuntimeProfile() != "saved-profile" {
|
||||
t.Fatal("token override state was not restored")
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
|
||||
runner.err = errors.New("runner failure")
|
||||
if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) {
|
||||
@@ -1462,14 +1475,11 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
|
||||
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
|
||||
t.Fatal("unsupported schema format succeeded")
|
||||
}
|
||||
for _, key := range []string{"", "unknown", personal.EventMention} {
|
||||
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
|
||||
if err := ensurePublicPersonalEvent(key); err != nil {
|
||||
t.Fatalf("public event %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(personal.EventFromUser); err == nil {
|
||||
t.Fatal("private event accepted")
|
||||
}
|
||||
|
||||
var cfg consume.Config
|
||||
applyPersonalConsumeFilters(nil, personalConsumeOptions{}, "", "")
|
||||
@@ -1604,7 +1614,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
|
||||
{"lookup", personalConsumeOptions{SubscribeID: "existing"}, true},
|
||||
{"create", personalConsumeOptions{EventKey: personal.EventMention, Name: "name", FilterJSON: `{"field":"content","op":"eq","value":"x"}`, QueryCSV: "a,b", TTL: time.Minute}, true},
|
||||
{"missing", personalConsumeOptions{}, false},
|
||||
{"private", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, false},
|
||||
{"sender", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, true},
|
||||
{"bad-rule", personalConsumeOptions{EventKey: personal.EventMention, Rule: "other"}, false},
|
||||
{"bad-filter", personalConsumeOptions{EventKey: personal.EventMention, FilterJSON: "{"}, false},
|
||||
} {
|
||||
@@ -1618,7 +1628,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
|
||||
}
|
||||
})
|
||||
}
|
||||
if createCount != 1 {
|
||||
if createCount != 2 {
|
||||
t.Fatalf("create count = %d", createCount)
|
||||
}
|
||||
|
||||
@@ -2000,6 +2010,31 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
data := &authpkg.TokenData{
|
||||
AccessToken: "token-" + profile.CorpID,
|
||||
CorpID: profile.CorpID,
|
||||
UserID: profile.UserID,
|
||||
}
|
||||
var err error
|
||||
if profile.UserID == "" {
|
||||
err = authpkg.SaveTokenDataKeychainForCorpID(profile.CorpID, data)
|
||||
} else {
|
||||
err = authpkg.SaveTokenDataKeychainForIdentity(profile.CorpID, profile.UserID, data)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.UserID == "" {
|
||||
_ = authpkg.DeleteTokenDataKeychainForCorpID(profile.CorpID)
|
||||
} else {
|
||||
_ = authpkg.DeleteTokenDataKeychainForIdentity(profile.CorpID, profile.UserID)
|
||||
}
|
||||
}
|
||||
})
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
@@ -2094,15 +2129,11 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
_ = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
for _, raw := range []string{"", "bad", now.Format(time.RFC3339)} {
|
||||
_, _ = parseProfileSwitchTime(raw)
|
||||
}
|
||||
for _, p := range cfg.Profiles {
|
||||
_, _ = profileSwitchSortTime(p)
|
||||
_ = profileSwitchOptionLabel(p, cfg)
|
||||
_, _ = profileSwitchProfileCells(p, cfg)
|
||||
}
|
||||
_ = profileSwitchProfileIndex(cfg.Profiles, "missing")
|
||||
_ = profileSwitchProfileIndex(cfg.Profiles, "missing", cfg)
|
||||
_ = profileSwitchBorder("a", "b", "c")
|
||||
_ = profileSwitchTableLine("org", "status")
|
||||
_ = profileSwitchStyledTableLine("org", "status", profileSwitchNormalRowStyle())
|
||||
@@ -2116,9 +2147,9 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
|
||||
_ = profileSwitchTitleStyle()
|
||||
_ = profileSwitchMutedStyle()
|
||||
|
||||
writeProfileListTable(io.Discard, nil)
|
||||
writeProfileListTable(io.Discard, cfg)
|
||||
if err := writeProfileListJSON(io.Discard, cfg); err != nil || writeProfileUseJSON(io.Discard, nil, nil) != nil || writeProfileUseJSON(io.Discard, &cfg.Profiles[0], cfg) != nil {
|
||||
writeProfileListTable(io.Discard, "", nil)
|
||||
writeProfileListTable(io.Discard, "", cfg)
|
||||
if err := writeProfileListJSON(io.Discard, "", cfg); err != nil || writeProfileUseJSON(io.Discard, nil, nil) != nil || writeProfileUseJSON(io.Discard, &cfg.Profiles[0], cfg) != nil {
|
||||
t.Fatal("profile JSON write failed")
|
||||
}
|
||||
_ = profileUseMessage(nil)
|
||||
@@ -2126,8 +2157,8 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
|
||||
_ = profileUseMessage(&p)
|
||||
_ = profileOrgName(p)
|
||||
}
|
||||
_ = profileViews(nil)
|
||||
_ = profileViews(cfg)
|
||||
_ = profileViews("", nil)
|
||||
_ = profileViews("", cfg)
|
||||
for _, limit := range []int{0, 2, 5, 40} {
|
||||
_ = clipProfileCell("abcdefgh", limit)
|
||||
_ = clipProfileDisplayCell("中文abcdefgh", limit)
|
||||
|
||||
@@ -174,6 +174,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
"ttl",
|
||||
"ephemeral",
|
||||
"user",
|
||||
"open-dingtalk-id",
|
||||
"group",
|
||||
"personal-event-base-url",
|
||||
); err != nil {
|
||||
@@ -312,7 +313,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
"或从外部先用 dws event stop <subscribe_id> --dry-run 预览、确认后加 --yes(会一并退订);"+
|
||||
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
|
||||
f.StringVar(&personalOpts.UserID, "user", "",
|
||||
"个人单聊对端 userId")
|
||||
"单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)")
|
||||
f.StringVar(&personalOpts.OpenDingTalkID, "open-dingtalk-id", "",
|
||||
"单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)")
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
"group 规则:openConversationId")
|
||||
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -69,6 +70,7 @@ type personalConsumeOptions struct {
|
||||
TTL time.Duration
|
||||
Ephemeral bool
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
GroupID string
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
@@ -226,8 +228,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents)
|
||||
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
}
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
@@ -240,6 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
@@ -272,8 +281,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
_ = 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
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
// unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
@@ -284,22 +293,24 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
ReadySubscribeID: sub.SubscribeID,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
@@ -354,6 +365,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return err
|
||||
}
|
||||
|
||||
func personalEventProjector(debugRawEvents bool) consume.Projector {
|
||||
if debugRawEvents {
|
||||
return func(ev transport.Event) (any, error) { return ev, nil }
|
||||
}
|
||||
return personal.ProjectOutput
|
||||
}
|
||||
|
||||
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -369,6 +387,19 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
||||
cfg.SubscribeID = strings.TrimSpace(subscribeID)
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
return err
|
||||
}
|
||||
|
||||
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 := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||
@@ -398,9 +429,10 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
GroupID: opts.GroupID,
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
@@ -800,15 +832,14 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
// Forward the exact account so the detached _bus child resolves the same
|
||||
// credentials as the parent, including when one organization has multiple
|
||||
// logged-in users.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
|
||||
CorpID: identity.CorpID,
|
||||
UserID: identity.UserID,
|
||||
}))
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
|
||||
"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/transport"
|
||||
)
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
cfg := consume.Config{EventKey: personal.EventSingleChat, ReadySubscribeID: "sub-1"}
|
||||
opts := personalConsumeOptions{
|
||||
DebugRawEvents: true,
|
||||
Common: commonConsumeOptions{
|
||||
@@ -34,6 +35,9 @@ func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
|
||||
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
if cfg.EventKey != personal.EventSingleChat || cfg.ReadySubscribeID != "sub-1" {
|
||||
t.Fatalf("raw debug cleared ready identity: eventKey=%q subscribeID=%q", cfg.EventKey, cfg.ReadySubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
@@ -48,6 +52,28 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
|
||||
if personalEventProjector(false) == nil {
|
||||
t.Fatal("normal personal consume projector = nil")
|
||||
}
|
||||
projector := personalEventProjector(true)
|
||||
if projector == nil {
|
||||
t.Fatal("debug raw personal consume projector = nil")
|
||||
}
|
||||
ev := transport.Event{
|
||||
EventID: "raw-event",
|
||||
Data: `{"payload":{"uid":100001,"bizid":"internal-bizid"}}`,
|
||||
Headers: map[string]string{"TOPIC": "raw"},
|
||||
}
|
||||
projected, err := projector(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := projected.(transport.Event); !ok || got.EventID != ev.EventID || got.Data != ev.Data || got.Headers["TOPIC"] != "raw" {
|
||||
t.Fatalf("debug raw projection = %#v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
@@ -72,7 +98,7 @@ func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
|
||||
|
||||
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
for _, name := range []string{"user", "group", "query"} {
|
||||
for _, name := range []string{"user", "open-dingtalk-id", "group", "query"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag --%s is not registered", name)
|
||||
}
|
||||
@@ -84,6 +110,7 @@ func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
"odid",
|
||||
} {
|
||||
if cmd.Flags().Lookup(name) != nil {
|
||||
t.Fatalf("retired flag --%s is still registered", name)
|
||||
@@ -99,6 +126,7 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
"odid",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
@@ -115,7 +143,8 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--as", "app", "--user", "507971"},
|
||||
{"--as", "app", "--user", "test-user-001"},
|
||||
{"--as", "app", "--open-dingtalk-id", "open-user-1"},
|
||||
{"--as", "app", "--group", "cid"},
|
||||
{"--as", "app", "--query", "报警"},
|
||||
} {
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
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")
|
||||
if _, key, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || key != personal.EventFromUser || rule != "sender" {
|
||||
t.Fatalf("sender subscription = %q %q, %v", key, rule, err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{EventKey: personal.EventMention}, nil
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -43,8 +45,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
}
|
||||
got := out.String()
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
for _, eventKey := range []string{
|
||||
personal.EventFromUser,
|
||||
personal.EventReadO2O,
|
||||
personal.EventReadGroup,
|
||||
personal.EventRecallO2O,
|
||||
personal.EventRecallGroup,
|
||||
personal.EventReactionO2O,
|
||||
personal.EventReactionGroup,
|
||||
} {
|
||||
if !strings.Contains(got, eventKey) {
|
||||
t.Fatalf("list output missing %s: %s", eventKey, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -64,8 +76,8 @@ func TestEventListDefaultsToUser(t *testing.T) {
|
||||
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
|
||||
t.Fatalf("list output = %s, want personal event catalog", got)
|
||||
}
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
if !strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output missing public event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
|
||||
t.Fatalf("list default appears to use legacy application output: %s", got)
|
||||
@@ -246,8 +258,8 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
|
||||
}
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
|
||||
if doc["jq_root_path"] != "." {
|
||||
t.Fatalf("jq_root_path = %#v, want .", doc["jq_root_path"])
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -264,6 +276,80 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventKeys []string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKeys: []string{personal.EventReadO2O, personal.EventReadGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
|
||||
"sender_open_dingtalk_id", "read_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKeys: []string{personal.EventRecallO2O, personal.EventRecallGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
|
||||
"sender_open_dingtalk_id", "recall_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKeys: []string{personal.EventReactionO2O, personal.EventReactionGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
|
||||
"reaction_text", "operation_type", "operation_time", "sender",
|
||||
"sender_open_dingtalk_id", "event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, eventKey := range tt.eventKeys {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema metadata = %#v", doc)
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema = %#v", doc["schema"])
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok || len(properties) != len(tt.properties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", schema["properties"], len(tt.properties))
|
||||
}
|
||||
for _, field := range tt.properties {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Fatalf("schema missing %q: %#v", field, properties)
|
||||
}
|
||||
}
|
||||
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
|
||||
if _, ok := properties[internal]; ok {
|
||||
t.Fatalf("schema exposed internal property %q", internal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
@@ -283,38 +369,144 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "schema",
|
||||
cmd: newEventSchemaCommand(),
|
||||
args: []string{personal.EventFromUser},
|
||||
},
|
||||
{
|
||||
name: "consume",
|
||||
cmd: newEventConsumeCommand(),
|
||||
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cmd: newEventStatusCommand(),
|
||||
args: []string{"--event", personal.EventFromUser},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.cmd.SilenceUsage = true
|
||||
tc.cmd.SilenceErrors = true
|
||||
tc.cmd.SetArgs(tc.args)
|
||||
err := tc.cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want not publicly available", err)
|
||||
}
|
||||
})
|
||||
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
|
||||
if err := ensurePublicPersonalEvent(personal.EventFromUser); err != nil {
|
||||
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
|
||||
}
|
||||
|
||||
schemaCmd := newEventSchemaCommand()
|
||||
schemaCmd.SilenceUsage = true
|
||||
schemaCmd.SilenceErrors = true
|
||||
var schemaOut bytes.Buffer
|
||||
schemaCmd.SetOut(&schemaOut)
|
||||
schemaCmd.SetArgs([]string{personal.EventFromUser})
|
||||
if err := schemaCmd.Execute(); err != nil {
|
||||
t.Fatalf("schema Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, schemaOut.String())
|
||||
}
|
||||
if doc["event_key"] != personal.EventFromUser || doc["rule_type"] != "sender" {
|
||||
t.Fatalf("schema document = %#v", doc)
|
||||
}
|
||||
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
consumeCmd := newEventConsumeCommand()
|
||||
consumeCmd.SilenceUsage = true
|
||||
consumeCmd.SilenceErrors = true
|
||||
consumeCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--dry-run"})
|
||||
if err := consumeCmd.Execute(); err != nil {
|
||||
t.Fatalf("consume dry-run Execute() error = %v", err)
|
||||
}
|
||||
openIDConsumeCmd := newEventConsumeCommand()
|
||||
openIDConsumeCmd.SilenceUsage = true
|
||||
openIDConsumeCmd.SilenceErrors = true
|
||||
openIDConsumeCmd.SetArgs([]string{personal.EventFromUser, "--open-dingtalk-id", "open-user-1", "--dry-run"})
|
||||
if err := openIDConsumeCmd.Execute(); err != nil {
|
||||
t.Fatalf("consume openDingtalkId dry-run Execute() error = %v", err)
|
||||
}
|
||||
|
||||
conflictingTargetCmd := newEventConsumeCommand()
|
||||
conflictingTargetCmd.SilenceUsage = true
|
||||
conflictingTargetCmd.SilenceErrors = true
|
||||
conflictingTargetCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--open-dingtalk-id", "open-user-1", "--dry-run"})
|
||||
err := conflictingTargetCmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+personal.EventFromUser) {
|
||||
t.Fatalf("conflicting target identity error = %v", err)
|
||||
}
|
||||
|
||||
groupOpenIDCmd := newEventConsumeCommand()
|
||||
groupOpenIDCmd.SilenceUsage = true
|
||||
groupOpenIDCmd.SilenceErrors = true
|
||||
groupOpenIDCmd.SetArgs([]string{personal.EventInChat, "--group", "cid-1", "--open-dingtalk-id", "open-user-1", "--dry-run"})
|
||||
err = groupOpenIDCmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+personal.EventInChat+"; use --group") {
|
||||
t.Fatalf("group openDingtalkId error = %v", err)
|
||||
}
|
||||
|
||||
missingUserCmd := newEventConsumeCommand()
|
||||
missingUserCmd.SilenceUsage = true
|
||||
missingUserCmd.SilenceErrors = true
|
||||
missingUserCmd.SetArgs([]string{personal.EventFromUser})
|
||||
err = missingUserCmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+personal.EventFromUser) {
|
||||
t.Fatalf("missing target identity error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
root.SilenceUsage = true
|
||||
root.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetArgs([]string{"schema", "event consume"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("schema event consume Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
params, ok := doc["parameters"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema parameters = %#v", doc["parameters"])
|
||||
}
|
||||
if _, ok := params["open-dingtalk-id"]; !ok {
|
||||
t.Fatalf("schema parameters missing open-dingtalk-id: %#v", params)
|
||||
}
|
||||
if _, ok := params["odid"]; ok {
|
||||
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
|
||||
}
|
||||
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
|
||||
param, ok := params[name].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema parameter %s = %#v", name, params[name])
|
||||
}
|
||||
if got, exists := param["required_when"]; exists {
|
||||
t.Fatalf("schema parameter %s unexpectedly declares required_when = %#v", name, got)
|
||||
}
|
||||
}
|
||||
constraints, ok := doc["constraints"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema constraints = %#v", doc["constraints"])
|
||||
}
|
||||
assertJSONConstraintGroup := func(field string, want []string) {
|
||||
t.Helper()
|
||||
groups, ok := constraints[field].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema constraint %s = %#v", field, constraints[field])
|
||||
}
|
||||
for _, rawGroup := range groups {
|
||||
group, ok := rawGroup.([]any)
|
||||
if !ok || len(group) != len(want) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for i := range want {
|
||||
if group[i] != want[i] {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
|
||||
}
|
||||
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
|
||||
@@ -41,17 +41,18 @@ func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
UserID: "user_123",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449:user_123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
t.Errorf("spawn args must forward --profile <corpId>:<userId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
|
||||
@@ -47,7 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
_ = cmd.PersistentFlags().MarkHidden("output")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定组织或账号;支持 corpId/corpName 与 userId/userName 组合,推荐使用 profile list 返回的 corpId:userId;多个按 CSV 逗号分隔")
|
||||
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
|
||||
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
|
||||
_ = cmd.PersistentFlags().MarkHidden("token")
|
||||
|
||||
@@ -50,8 +50,9 @@ func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
|
||||
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
|
||||
}
|
||||
resultPayload := entry["result"].(map[string]any)
|
||||
if resultPayload["runtimeProfile"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
|
||||
wantProfile := wantCorpID + ":user-" + wantCorpID
|
||||
if resultPayload["runtimeProfile"] != wantProfile {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +76,37 @@ func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_same")
|
||||
first.UserID = "user_1"
|
||||
second := authLogoutTestToken("corp_same")
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
configDir := setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(
|
||||
configDir,
|
||||
"corp_same,corp_same:user_1,corp_same:user_2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi {
|
||||
t.Fatal("multi = false, want true")
|
||||
}
|
||||
if len(selections) != 2 {
|
||||
t.Fatalf("selections len = %d, want 2: %#v", len(selections), selections)
|
||||
}
|
||||
got := []string{
|
||||
authpkg.ProfileSelector(selections[0].Profile),
|
||||
authpkg.ProfileSelector(selections[1].Profile),
|
||||
}
|
||||
if strings.Join(got, ",") != "corp_same:user_2,corp_same:user_1" {
|
||||
t.Fatalf("resolved identities = %v, want current user_2 then user_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
@@ -91,11 +123,38 @@ func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
|
||||
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
|
||||
}
|
||||
if got := result.Response["content"].(map[string]any)["runtimeProfile"]; got != "corp_a:user-corp_a" {
|
||||
t.Fatalf("fallback runtime profile = %#v, want exact identity selector", got)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a" {
|
||||
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerRejectsAmbiguousSingleProfile(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_first")
|
||||
first.CorpName = "Shared Org"
|
||||
second := authLogoutTestToken("corp_second")
|
||||
second.CorpName = "Shared Org"
|
||||
setupAuthLogoutProfiles(t, first, second)
|
||||
authpkg.SetRuntimeProfile("Shared Org")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
_, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Run() accepted ambiguous single profile selector")
|
||||
}
|
||||
for _, candidate := range []string{"corp_first", "corp_second"} {
|
||||
if !strings.Contains(err.Error(), candidate) {
|
||||
t.Fatalf("error = %q, want candidate %q", err.Error(), candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
|
||||
+184
-89
@@ -18,7 +18,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,15 +33,17 @@ func newProfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "组织 profile 管理",
|
||||
Long: `管理本机已登录的钉钉组织 profile。
|
||||
Long: `管理本机已登录的钉钉账号 profile。
|
||||
|
||||
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
|
||||
profile switch/use 才会持久修改默认组织上下文。`,
|
||||
每个 profile 由 corpId + userId 唯一确定,同一组织可保存多个账号。业务命令可通过
|
||||
全局 --profile 临时指定组织或账号,profile switch/use 才会持久修改默认账号。`,
|
||||
Example: ` dws profile list
|
||||
dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch <corpId>:<userId>
|
||||
dws profile switch "<corpName>:<userName>"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
dws --profile <corpId>:<userId> contact user get-self`,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -58,8 +59,8 @@ func newProfileListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出已登录组织 profile",
|
||||
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
|
||||
Short: "列出全部已登录账号 profile",
|
||||
Long: "列出本机全部已登录账号。状态和到期时间直接读取各身份 Token,列表本身不会刷新 Token。",
|
||||
Example: ` dws profile list
|
||||
dws profile list --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
@@ -75,9 +76,9 @@ func newProfileListCommand() *cobra.Command {
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), configDir, cfg)
|
||||
}
|
||||
writeProfileListTable(cmd.OutOrStdout(), cfg)
|
||||
writeProfileListTable(cmd.OutOrStdout(), configDir, cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -85,9 +86,9 @@ func newProfileListCommand() *cobra.Command {
|
||||
|
||||
func newProfileUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [name|corpId|-]",
|
||||
Short: "切换当前组织 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
|
||||
Use: "use [profile-selector|-]",
|
||||
Short: "切换当前账号 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。选择器支持组织 ID/名称、账号 ID/名称组合或本地 profile 名;- 切回上一个账号。",
|
||||
Example: ` dws profile use <corpId>
|
||||
dws profile use --name "钉钉"
|
||||
dws profile use -`,
|
||||
@@ -103,18 +104,21 @@ func newProfileUseCommand() *cobra.Command {
|
||||
|
||||
func newProfileSwitchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "switch [name|corpId|-]",
|
||||
Short: "切换当前组织 profile",
|
||||
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
Use: "switch [profile-selector|-]",
|
||||
Short: "切换当前账号 profile",
|
||||
Long: `切换默认账号 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
|
||||
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
|
||||
需要只影响单次业务命令时,请使用全局 --profile。`,
|
||||
选择器支持 corpId:userId、corpId:userName、corpName:userId、corpName:userName,
|
||||
也兼容单独的 corpId、唯一 corpName 和本地 profile 名。组织或账号名称重名时会报错,
|
||||
要求改用稳定的 corpId:userId。不带参数时交互选择;单次执行请使用全局 --profile。`,
|
||||
Example: ` dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch <corpId>:<userId>
|
||||
dws profile switch "<corpName>:<userName>"
|
||||
dws profile switch --corpId <corpId>
|
||||
dws profile switch --name "钉钉"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
dws --profile <corpId>:<userId> contact user get-self`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -142,6 +146,7 @@ var (
|
||||
profileSwitchTUIRunner = runProfileSwitchTUI
|
||||
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||
profileLoadProfiles = authpkg.LoadProfiles
|
||||
profileLoadTokenData = authpkg.LoadTokenDataForProfile
|
||||
profileUsePrevious = authpkg.UsePreviousProfile
|
||||
profileSetCurrent = authpkg.SetCurrentProfile
|
||||
profileRunTeaProgram = (*tea.Program).Run
|
||||
@@ -247,7 +252,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
|
||||
|
||||
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
|
||||
if !profileSwitchInteractiveTerminal() {
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <corpId|corpId:userId|corpName:userName>")
|
||||
}
|
||||
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
@@ -261,10 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = strings.TrimSpace(cfg.PrimaryProfile)
|
||||
}
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
choice = authpkg.ProfileSelector(cfg.Profiles[0])
|
||||
}
|
||||
return profileSwitchTUIRunner(cmd, cfg, choice)
|
||||
}
|
||||
@@ -306,7 +308,7 @@ func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string
|
||||
if cfg != nil {
|
||||
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
}
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID, cfg)
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
@@ -315,40 +317,7 @@ func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string
|
||||
}
|
||||
|
||||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||||
sorted := append([]authpkg.Profile(nil), profiles...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left, leftOK := profileSwitchSortTime(sorted[i])
|
||||
right, rightOK := profileSwitchSortTime(sorted[j])
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return false
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
|
||||
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
|
||||
if t, ok := parseProfileSwitchTime(raw); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseProfileSwitchTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
return append([]authpkg.Profile(nil), profiles...)
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Init() tea.Cmd {
|
||||
@@ -459,17 +428,31 @@ func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.profiles[m.selected].CorpID)
|
||||
return authpkg.ProfileSelector(m.profiles[m.selected])
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
fallback := -1
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID {
|
||||
return i
|
||||
if strings.TrimSpace(p.CorpID) == selector {
|
||||
if fallback < 0 {
|
||||
fallback = i
|
||||
}
|
||||
if profileIsOrgCurrent(p, cfg) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
return fallback
|
||||
}
|
||||
|
||||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
@@ -481,11 +464,29 @@ func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) st
|
||||
}
|
||||
|
||||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||||
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
|
||||
orgName := profileOrgName(p)
|
||||
if cfg != nil {
|
||||
sameCorp := 0
|
||||
for _, candidate := range cfg.Profiles {
|
||||
if candidate.CorpID == p.CorpID {
|
||||
sameCorp++
|
||||
}
|
||||
}
|
||||
if sameCorp > 1 {
|
||||
user := strings.TrimSpace(p.UserName)
|
||||
if user == "" {
|
||||
user = strings.TrimSpace(p.UserID)
|
||||
}
|
||||
if user != "" {
|
||||
orgName += " / " + user
|
||||
}
|
||||
}
|
||||
}
|
||||
return orgName, profileSwitchProfileStatus(p, cfg)
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && p.CorpID == cfg.CurrentProfile {
|
||||
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
@@ -575,6 +576,7 @@ type profileUseResponse struct {
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
Profile string `json:"profile"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
@@ -588,15 +590,16 @@ type profileView struct {
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
IsOrgCurrent bool `json:"isOrgCurrent"`
|
||||
}
|
||||
|
||||
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
|
||||
func writeProfileListJSON(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileListResponse{
|
||||
Success: true,
|
||||
PrimaryProfile: cfg.PrimaryProfile,
|
||||
CurrentProfile: cfg.CurrentProfile,
|
||||
PreviousProfile: cfg.PreviousProfile,
|
||||
Profiles: profileViews(cfg),
|
||||
Profiles: profileViews(configDir, cfg),
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
@@ -612,44 +615,50 @@ func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.Pro
|
||||
primaryProfile = cfg.PrimaryProfile
|
||||
currentProfile = cfg.CurrentProfile
|
||||
}
|
||||
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
|
||||
resp.Profile = profileViewFromProfile(
|
||||
*profile,
|
||||
cfg,
|
||||
primaryProfile,
|
||||
currentProfile,
|
||||
profileCountForCorp(cfg, profile.CorpID) <= 1,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
|
||||
func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
fmt.Fprintln(w, "未找到已登录 profile")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
view := profileViewFromProfile(
|
||||
p,
|
||||
cfg,
|
||||
cfg.PrimaryProfile,
|
||||
cfg.CurrentProfile,
|
||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||
loadProfileTokenState(configDir, p),
|
||||
)
|
||||
current := ""
|
||||
if p.CorpID == cfg.CurrentProfile {
|
||||
if view.IsCurrent {
|
||||
current = "*"
|
||||
}
|
||||
primary := ""
|
||||
if p.CorpID == cfg.PrimaryProfile {
|
||||
primary = "*"
|
||||
}
|
||||
user := p.UserName
|
||||
if user == "" {
|
||||
user = p.UserID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%-3s %-3s %-28s %-34s %-10s %s\n",
|
||||
"%-3s %-28s %-34s %-10s %s\n",
|
||||
current,
|
||||
primary,
|
||||
clipProfileCell(profileOrgName(p), 28),
|
||||
clipProfileCell(p.CorpID, 34),
|
||||
status,
|
||||
view.Status,
|
||||
user,
|
||||
)
|
||||
}
|
||||
@@ -677,19 +686,40 @@ func profileOrgName(p authpkg.Profile) string {
|
||||
return strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
|
||||
type profileTokenState struct {
|
||||
Status string
|
||||
ExpiresAt string
|
||||
RefreshExpAt string
|
||||
}
|
||||
|
||||
func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
|
||||
views = append(views, profileViewFromProfile(
|
||||
p,
|
||||
cfg,
|
||||
cfg.PrimaryProfile,
|
||||
cfg.CurrentProfile,
|
||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||
loadProfileTokenState(configDir, p),
|
||||
))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
|
||||
return profileView{
|
||||
func profileViewFromProfile(
|
||||
p authpkg.Profile,
|
||||
cfg *authpkg.ProfilesConfig,
|
||||
primaryProfile, currentProfile string,
|
||||
onlyAccountInOrg bool,
|
||||
tokenState *profileTokenState,
|
||||
) profileView {
|
||||
isOrgCurrent := profileIsOrgCurrent(p, cfg)
|
||||
view := profileView{
|
||||
Profile: authpkg.ProfileSelector(p),
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
@@ -701,9 +731,74 @@ func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile st
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: p.CorpID == primaryProfile,
|
||||
IsCurrent: p.CorpID == currentProfile,
|
||||
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg),
|
||||
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg),
|
||||
IsOrgCurrent: isOrgCurrent,
|
||||
}
|
||||
if tokenState != nil {
|
||||
view.Status = tokenState.Status
|
||||
view.ExpiresAt = tokenState.ExpiresAt
|
||||
view.RefreshExpAt = tokenState.RefreshExpAt
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState {
|
||||
data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile))
|
||||
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
|
||||
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
|
||||
}
|
||||
if err != nil {
|
||||
return &profileTokenState{Status: authpkg.ProfileStatusUnavailable}
|
||||
}
|
||||
status := authpkg.ProfileStatusExpired
|
||||
if data.IsAccessTokenValid() {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
return &profileTokenState{
|
||||
Status: status,
|
||||
ExpiresAt: profileTokenTime(data.ExpiresAt),
|
||||
RefreshExpAt: profileTokenTime(data.RefreshExpAt),
|
||||
}
|
||||
}
|
||||
|
||||
func profileTokenTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, isOrgCurrent, onlyAccountInOrg bool) bool {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
return selector == strings.TrimSpace(profile.CorpID) && (isOrgCurrent || onlyAccountInOrg)
|
||||
}
|
||||
|
||||
func profileCountForCorp(cfg *authpkg.ProfilesConfig, corpID string) int {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, profile := range cfg.Profiles {
|
||||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(corpID) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func profileIsOrgCurrent(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
selector := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(profile.CorpID)])
|
||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
return profileCountForCorp(cfg, profile.CorpID) == 1
|
||||
}
|
||||
|
||||
func clipProfileCell(value string, limit int) string {
|
||||
|
||||
@@ -16,9 +16,11 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -80,8 +82,10 @@ func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false, want true")
|
||||
}
|
||||
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
if resp.PrimaryProfile != "" ||
|
||||
resp.CurrentProfile != "corp_secondary:user-corp_secondary" ||
|
||||
resp.PreviousProfile != "corp_primary:user-corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||||
@@ -96,6 +100,181 @@ func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRootCommandJSONIncludesAllAccountsInSameCorp(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_same")
|
||||
first.UserID = "user_1"
|
||||
first.UserName = "账号一"
|
||||
second := authLogoutTestToken("corp_same")
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
second.UserName = "账号二"
|
||||
setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2: %#v", len(resp.Profiles), resp.Profiles)
|
||||
}
|
||||
got := make(map[string]profileView, len(resp.Profiles))
|
||||
for _, profile := range resp.Profiles {
|
||||
got[profile.Profile] = profile
|
||||
}
|
||||
if _, ok := got["corp_same:user_1"]; !ok {
|
||||
t.Fatalf("profiles missing corp_same:user_1: %#v", resp.Profiles)
|
||||
}
|
||||
current, ok := got["corp_same:user_2"]
|
||||
if !ok {
|
||||
t.Fatalf("profiles missing corp_same:user_2: %#v", resp.Profiles)
|
||||
}
|
||||
if !current.IsOrgCurrent || !current.IsCurrent || current.IsPrimary {
|
||||
t.Fatalf("last login account markers = %#v, want org-current/current and deprecated primary=false", current)
|
||||
}
|
||||
if got["corp_same:user_1"].IsOrgCurrent {
|
||||
t.Fatalf("older account unexpectedly marked org current: %#v", got["corp_same:user_1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListUsesRealIdentityTokenState(t *testing.T) {
|
||||
token := authLogoutTestToken("corp_real")
|
||||
token.ExpiresAt = time.Date(2026, 7, 16, 17, 38, 0, 0, time.Local)
|
||||
token.RefreshExpAt = time.Date(2026, 8, 16, 17, 38, 0, 0, time.Local)
|
||||
configDir := setupAuthLogoutProfiles(t, token)
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.Profiles[0].Status = authpkg.ProfileStatusActive
|
||||
cfg.Profiles[0].ExpiresAt = "2026-07-16T22:29:00+08:00"
|
||||
cfg.Profiles[0].RefreshExpAt = "2026-09-16T22:29:00+08:00"
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(resp.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1", len(resp.Profiles))
|
||||
}
|
||||
got := resp.Profiles[0]
|
||||
if got.ExpiresAt != token.ExpiresAt.Format(time.RFC3339) {
|
||||
t.Fatalf("expiresAt = %q, want real token %q", got.ExpiresAt, token.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
if got.RefreshExpAt != token.RefreshExpAt.Format(time.RFC3339) {
|
||||
t.Fatalf("refreshExpAt = %q, want real token %q", got.RefreshExpAt, token.RefreshExpAt.Format(time.RFC3339))
|
||||
}
|
||||
if got.Status != authpkg.ProfileStatusExpired {
|
||||
t.Fatalf("status = %q, want expired", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListDistinguishesMissingAndUnavailableTokenState(t *testing.T) {
|
||||
originalLoad := profileLoadTokenData
|
||||
t.Cleanup(func() { profileLoadTokenData = originalLoad })
|
||||
profile := authpkg.Profile{CorpID: "corp", UserID: "user"}
|
||||
|
||||
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
|
||||
return nil, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusRevoked {
|
||||
t.Fatalf("missing token status = %q, want revoked", state.Status)
|
||||
}
|
||||
|
||||
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("keychain unavailable")
|
||||
}
|
||||
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusUnavailable {
|
||||
t.Fatalf("unavailable token status = %q, want unavailable", state.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListCurrentFlagsUseStoredExactSelectors(t *testing.T) {
|
||||
first := authLogoutTestToken("corp_same")
|
||||
first.UserID = "user_1"
|
||||
first.UserName = "账号一"
|
||||
second := authLogoutTestToken("corp_same")
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
second.UserName = "账号二"
|
||||
configDir := setupAuthLogoutProfiles(t, first, second)
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.PrimaryProfile = ""
|
||||
cfg.CurrentProfile = "corp_same:user_1"
|
||||
cfg.OrgCurrentProfiles["corp_same"] = "corp_same:user_1"
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
got := make(map[string]profileView, len(resp.Profiles))
|
||||
for _, profile := range resp.Profiles {
|
||||
got[profile.Profile] = profile
|
||||
}
|
||||
if !got["corp_same:user_1"].IsCurrent || !got["corp_same:user_1"].IsOrgCurrent {
|
||||
t.Fatalf("first account flags = %#v, want current and org current", got["corp_same:user_1"])
|
||||
}
|
||||
if got["corp_same:user_2"].IsCurrent || got["corp_same:user_2"].IsOrgCurrent {
|
||||
t.Fatalf("second account flags = %#v, want neither current nor org current", got["corp_same:user_2"])
|
||||
}
|
||||
if got["corp_same:user_1"].IsPrimary || got["corp_same:user_2"].IsPrimary {
|
||||
t.Fatalf("deprecated isPrimary should be false without primaryProfile: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListTableOmitsDeprecatedPrimaryColumn(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_table"))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
header := strings.SplitN(out.String(), "\n", 2)[0]
|
||||
if strings.Contains(header, "PRI") {
|
||||
t.Fatalf("profile list header still contains deprecated PRI column: %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
@@ -118,8 +297,9 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
|
||||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
@@ -145,8 +325,9 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" ||
|
||||
cfg.PreviousProfile != "corp_primary:user-corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
@@ -178,8 +359,9 @@ func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *t
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
|
||||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
@@ -209,8 +391,8 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
@@ -226,8 +408,8 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
|
||||
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary:user-corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +469,8 @@ func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +540,7 @@ func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
func TestProfileSwitchTUIPreservesStoredOrderInsteadOfSortingByTime(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "old",
|
||||
CurrentProfile: "old",
|
||||
@@ -370,7 +552,7 @@ func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, "old")
|
||||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||||
wantOrder := []string{"new", "fallback", "old"}
|
||||
wantOrder := []string{"old", "new", "fallback"}
|
||||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
@@ -507,8 +689,8 @@ func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +731,7 @@ func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeProfileListTable(&buf, cfg)
|
||||
writeProfileListTable(&buf, "", cfg)
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"ORG_NAME",
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageProfileRemainingCoverage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" {
|
||||
if len(choices) != 3 || choices[0] != "current" || choices[1] != "first" || choices[2] != "first" {
|
||||
t.Fatalf("profile choices = %#v", choices)
|
||||
}
|
||||
|
||||
|
||||
+22
-9
@@ -24,6 +24,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -748,7 +749,10 @@ func validateOptionalPath(flagName, path string) error {
|
||||
|
||||
// fileLogger holds the package-level file logger for diagnostics.
|
||||
// It is initialized by configureLogLevel and closed by CloseFileLogger.
|
||||
var fileLogger *logging.FileLogger
|
||||
var (
|
||||
fileLoggerMu sync.Mutex
|
||||
fileLogger *logging.FileLogger
|
||||
)
|
||||
|
||||
// configureLogLevel sets the global slog level based on --debug and --verbose flags
|
||||
// and initializes the file logger for diagnostics.
|
||||
@@ -770,18 +774,24 @@ func configureLogLevel(flags *GlobalFlags) {
|
||||
|
||||
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
|
||||
// regardless of stderr level. All slog calls are captured for diagnostics.
|
||||
// A previously closed rotating writer may have been reopened by slog calls
|
||||
// made while constructing another root command, so close it before replacing
|
||||
// the package pointer during reentrant executions.
|
||||
CloseFileLogger()
|
||||
fileLogger = logging.Setup(defaultConfigDir())
|
||||
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
logger := logging.Setup(defaultConfigDir())
|
||||
fileHandler := slog.NewJSONHandler(logger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
defaultLogger := slog.New(logging.NewMultiHandler(stderrHandler, fileHandler))
|
||||
|
||||
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
previous := fileLogger
|
||||
fileLogger = logger
|
||||
slog.SetDefault(defaultLogger)
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
|
||||
func FileLoggerInstance() *slog.Logger {
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
if fileLogger == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -790,8 +800,11 @@ func FileLoggerInstance() *slog.Logger {
|
||||
|
||||
// CloseFileLogger flushes and closes the file logger.
|
||||
func CloseFileLogger() {
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
if fileLogger != nil {
|
||||
fileLogger.Close()
|
||||
_ = fileLogger.Close()
|
||||
fileLogger = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -193,13 +193,25 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
rawProfile := authpkg.RuntimeProfile()
|
||||
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), rawProfile)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if multi {
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
if strings.TrimSpace(rawProfile) != "" {
|
||||
profile, err := authpkg.ResolveProfile(defaultConfigDir(), rawProfile)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if profile == nil {
|
||||
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
|
||||
}
|
||||
authpkg.SetRuntimeProfile(authpkg.ProfileSelector(*profile))
|
||||
defer authpkg.SetRuntimeProfile(rawProfile)
|
||||
}
|
||||
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
@@ -313,10 +325,11 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
||||
if profile == nil {
|
||||
return nil, false, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
if seen[profile.CorpID] {
|
||||
identitySelector := authpkg.ProfileSelector(*profile)
|
||||
if seen[identitySelector] {
|
||||
continue
|
||||
}
|
||||
seen[profile.CorpID] = true
|
||||
seen[identitySelector] = true
|
||||
selections = append(selections, multiProfileSelection{
|
||||
Selector: selector,
|
||||
Profile: *profile,
|
||||
@@ -334,13 +347,17 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
|
||||
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
entry := map[string]any{
|
||||
"selector": selection.Selector,
|
||||
"profile": resolvedSelector,
|
||||
"corpId": selection.Profile.CorpID,
|
||||
"corpName": selection.Profile.CorpName,
|
||||
"userId": selection.Profile.UserID,
|
||||
"userName": selection.Profile.UserName,
|
||||
"ok": err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
var (
|
||||
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
|
||||
)
|
||||
|
||||
// TestManualAgentExamplesContract is the always-on gate. It validates every
|
||||
@@ -67,7 +68,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")
|
||||
@@ -391,6 +392,9 @@ func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
|
||||
|
||||
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
|
||||
normalized := strings.ToLower(capture.Output)
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
}
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewRequest, true
|
||||
}
|
||||
@@ -428,6 +432,22 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
|
||||
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{
|
||||
Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
})
|
||||
if !observed || kind != cli.DryRunPreviewPlan {
|
||||
t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed)
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{
|
||||
Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
}) {
|
||||
t.Fatal("non-dry-run structured plan was accepted as evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func manualAgentExamplePromptObserved(output string) bool {
|
||||
normalized := strings.ToLower(output)
|
||||
for _, marker := range []string{
|
||||
@@ -470,7 +490,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 +538,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{
|
||||
|
||||
@@ -61,15 +61,24 @@ func TestEventRegistryDeliversOneTypedSchemaPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for flag, wantType := range map[string]string{
|
||||
"dry-run": "boolean",
|
||||
"duration": "string",
|
||||
"event-types": "array",
|
||||
"max-events": "integer",
|
||||
"dry-run": "boolean",
|
||||
"duration": "string",
|
||||
"event-types": "array",
|
||||
"max-events": "integer",
|
||||
"open-dingtalk-id": "string",
|
||||
} {
|
||||
if got := schemaContractString(consumeParams[flag]["type"]); got != wantType {
|
||||
t.Errorf("event.consume --%s type = %q, want %q", flag, got, wantType)
|
||||
}
|
||||
}
|
||||
if _, exists := consumeParams["odid"]; exists {
|
||||
t.Error("event.consume exposes unsupported --odid alias")
|
||||
}
|
||||
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
|
||||
if _, exists := consumeParams[name]["required_when"]; exists {
|
||||
t.Errorf("event.consume --%s unexpectedly declares required_when", name)
|
||||
}
|
||||
}
|
||||
if _, exists := consumeParams["duration"]["default"]; exists {
|
||||
t.Error("event.consume --duration leaked zero default 0s")
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ func TestSkillInstallCommandValidation(t *testing.T) {
|
||||
|
||||
func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
@@ -392,6 +393,7 @@ func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
// Setup: Create config directory without token
|
||||
tempDir := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
|
||||
// Isolate HOME so the ~/.dws/skills/<mode>/ fallback (added by the release
|
||||
// pipeline cache work) does not pick up real cached content on the
|
||||
// developer machine.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
_, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when source missing")
|
||||
@@ -110,8 +110,9 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if !strings.Contains(got[0], filepath.Join(".claude", "skills", "dws")) {
|
||||
t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
|
||||
want := filepath.Join(home, ".claude", "skills", "dws")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,11 +134,9 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if filepath.Base(got[0]) == "dws" {
|
||||
t.Fatalf("multi target must not end with /dws, got %s", got[0])
|
||||
}
|
||||
if !strings.HasSuffix(got[0], filepath.Join(".claude", "skills")) {
|
||||
t.Fatalf("expected suffix .claude/skills, got %s", got[0])
|
||||
want := filepath.Join(home, ".claude", "skills")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +17,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
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/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -27,8 +29,9 @@ import (
|
||||
// interface so that private overlays can invoke MCP tools without importing
|
||||
// internal packages.
|
||||
type toolCallerAdapter struct {
|
||||
runner executor.Runner
|
||||
flags *GlobalFlags
|
||||
runner executor.Runner
|
||||
flags *GlobalFlags
|
||||
tokenMu sync.Mutex
|
||||
}
|
||||
|
||||
var toolCallerDryRun = func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
@@ -63,6 +66,26 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
// CallToolWithToken invokes a helper with an in-memory token override. It is
|
||||
// used during login before the new token has been persisted to any profile
|
||||
// slot.
|
||||
func (a *toolCallerAdapter) CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if a == nil || a.flags == nil {
|
||||
return nil, fmt.Errorf("ToolCaller token override is not configured")
|
||||
}
|
||||
a.tokenMu.Lock()
|
||||
defer a.tokenMu.Unlock()
|
||||
previousToken := a.flags.Token
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
a.flags.Token = token
|
||||
authpkg.SetRuntimeProfile("")
|
||||
defer func() {
|
||||
a.flags.Token = previousToken
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
}()
|
||||
return a.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.Format
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestToolCallerTokenOverrideClearsUnpersistedRuntimeProfile(t *testing.T) {
|
||||
authpkg.SetRuntimeProfile("corp_not_persisted")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
flags := &GlobalFlags{}
|
||||
runner := runtimeProfileCaptureRunner{flags: flags}
|
||||
caller := &toolCallerAdapter{runner: runner, flags: flags}
|
||||
result, err := caller.CallToolWithToken(
|
||||
context.Background(),
|
||||
"temporary-access-token",
|
||||
"contact",
|
||||
"get_current_user_profile",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CallToolWithToken() error = %v", err)
|
||||
}
|
||||
if got := result.Content[0].Text; got != `{"profile":"","token":"temporary-access-token"}` {
|
||||
t.Fatalf("CallToolWithToken() result = %s", got)
|
||||
}
|
||||
if authpkg.RuntimeProfile() != "corp_not_persisted" {
|
||||
t.Fatalf("runtime profile = %q, want restored selector", authpkg.RuntimeProfile())
|
||||
}
|
||||
if flags.Token != "" {
|
||||
t.Fatalf("token override leaked after call: %q", flags.Token)
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeProfileCaptureRunner struct {
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func (r runtimeProfileCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": []any{map[string]any{
|
||||
"type": "text",
|
||||
"text": `{"profile":"` + authpkg.RuntimeProfile() + `","token":"` + r.flags.Token + `"}`,
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -38,6 +39,21 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) CallToolWithToken(
|
||||
ctx context.Context,
|
||||
token, product, tool string,
|
||||
args map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
inner, ok := r.inner.(tokenOverrideToolCaller)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ToolCaller token override is not configured")
|
||||
}
|
||||
recordedArgs := cloneToolArgs(args)
|
||||
res, err := inner.CallToolWithToken(ctx, token, product, tool, args)
|
||||
usage.Append(product, tool, recordedArgs, err == nil, r.inner.DryRun())
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) Format() string { return r.inner.Format() }
|
||||
func (r recordingToolCaller) DryRun() bool { return r.inner.DryRun() }
|
||||
func (r recordingToolCaller) Fields() string { return r.inner.Fields() }
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
type crossPlatformCoverageCaller struct {
|
||||
args map[string]any
|
||||
token string
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
@@ -33,6 +34,16 @@ func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, a
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (c *crossPlatformCoverageCaller) CallToolWithToken(
|
||||
_ context.Context,
|
||||
token, _, _ string,
|
||||
args map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
c.token = token
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*crossPlatformCoverageCaller) Format() string { return "json" }
|
||||
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
|
||||
@@ -88,6 +99,23 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
|
||||
t.Fatal("sensitive text must not be recorded")
|
||||
}
|
||||
|
||||
tokenCaller, ok := caller.(tokenOverrideToolCaller)
|
||||
if !ok {
|
||||
t.Fatal("recording caller dropped token override support")
|
||||
}
|
||||
if _, err := tokenCaller.CallToolWithToken(
|
||||
context.Background(),
|
||||
"temporary-token",
|
||||
"contact",
|
||||
"get_current_user_profile",
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.token != "temporary-token" || len(inner.args) != 0 {
|
||||
t.Fatalf("token override forwarding = token %q args %#v", inner.token, inner.args)
|
||||
}
|
||||
|
||||
inner.dryRun = true
|
||||
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -96,7 +124,7 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("dry-run call must not be recorded: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,43 @@ func TestRevokeTokenRemote(t *testing.T) {
|
||||
// Can't easily test since LogoutURL is a const. Just test that it doesn't panic with real URL.
|
||||
}
|
||||
|
||||
func TestRevokeTokenRemoteForDataUsesExactTokenMetadata(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
var got struct {
|
||||
ClientID string `json:"clientId"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != MCPRevokeTokenPath {
|
||||
t.Errorf("revoke path = %q, want %q", r.URL.Path, MCPRevokeTokenPath)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode revoke body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(srv.URL), 0o600); err != nil {
|
||||
t.Fatalf("write mcp_url: %v", err)
|
||||
}
|
||||
SetClientID("wrong-global-client")
|
||||
t.Cleanup(func() { SetClientID("") })
|
||||
|
||||
data := &TokenData{
|
||||
AccessToken: "exact-account-token",
|
||||
ClientID: "exact-account-client",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := RevokeTokenRemoteForData(t.Context(), data); err != nil {
|
||||
t.Fatalf("RevokeTokenRemoteForData() error = %v", err)
|
||||
}
|
||||
if got.ClientID != data.ClientID || got.AccessToken != data.AccessToken {
|
||||
t.Fatalf("revoke body = %#v, want exact token metadata", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── oauth_helpers.go ──────────────────────────────────────────────────
|
||||
|
||||
type tokenResponse struct {
|
||||
|
||||
@@ -495,6 +495,10 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
IdentityEnricher: func(_ context.Context, data *TokenData) error {
|
||||
data.UserID = "user123"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
@@ -396,7 +396,8 @@ func TestCrossPlatformCoverageDeviceFlowLoginRetry(t *testing.T) {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "device-access", "refreshToken": "refresh", "expiresIn": 7200, "corpId": "corp-device",
|
||||
"accessToken": "device-access", "refreshToken": "refresh", "expiresIn": 7200,
|
||||
"corpId": "corp-device", "userId": "user-device",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
_ = json.NewEncoder(w).Encode(CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}})
|
||||
@@ -493,7 +494,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].Name != "Acme" ||
|
||||
cfg.PrimaryProfile != "corp-a" || cfg.CurrentProfile != "corp-a" || cfg.PreviousProfile != "" {
|
||||
cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" {
|
||||
t.Fatalf("normalized profiles = %#v", cfg)
|
||||
}
|
||||
|
||||
@@ -515,6 +516,16 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
|
||||
if err := UpsertProfileFromTokenWithCurrent(dir, second, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity(first.CorpID, first.UserID, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID(second.CorpID, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = DeleteTokenDataKeychainForIdentity(first.CorpID, first.UserID)
|
||||
_ = DeleteTokenDataKeychainForCorpID(second.CorpID)
|
||||
})
|
||||
profilesForAmbiguity, err := LoadProfiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -575,7 +586,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
|
||||
{CorpID: "two", Name: "Acme-12345678"},
|
||||
{CorpID: "three", Name: "Acme-12345678-2"},
|
||||
}}
|
||||
if got := chooseProfileName(nameCfg, &TokenData{CorpID: "corp-12345678", CorpName: "Acme"}); got != "Acme-12345678-3" {
|
||||
if got := chooseProfileName(nameCfg, &TokenData{CorpID: "corp-12345678", CorpName: "Acme"}); got != "Acme-2" {
|
||||
t.Fatalf("collision profile name = %q", got)
|
||||
}
|
||||
if chooseProfileName(&ProfilesConfig{}, &TokenData{}) != "profile" {
|
||||
@@ -585,12 +596,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
|
||||
!shouldRefreshProfileName(&Profile{}, first) {
|
||||
t.Fatal("profile refresh-name decisions failed")
|
||||
}
|
||||
if findProfile(nil, "x") != nil || findProfile(cfg, "") != nil ||
|
||||
profileIndexByCorpID(nil, "x") != -1 || firstProfileCorpID(nil) != "" {
|
||||
t.Fatal("nil profile helper failed")
|
||||
}
|
||||
if shortCorpID("short") != "short" || shortCorpID("corp-12345678") != "12345678" ||
|
||||
timeOrRFC3339(time.Time{}) != "" {
|
||||
if shortCorpID("short") != "short" || shortCorpID("corp-12345678") != "12345678" {
|
||||
t.Fatal("profile formatting helpers failed")
|
||||
}
|
||||
}
|
||||
@@ -1234,6 +1240,12 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("import validation", func(t *testing.T) {
|
||||
oldGOOS := portableRuntimeGOOS
|
||||
t.Cleanup(func() {
|
||||
portableRuntimeGOOS = oldGOOS
|
||||
})
|
||||
portableRuntimeGOOS = func() string { return "linux" }
|
||||
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
if _, err := ImportPortableAuthBundle(t.TempDir(), nil); err == nil {
|
||||
t.Fatal("nil portable reader succeeded")
|
||||
@@ -1443,31 +1455,43 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
oldMarshalIndent := tokenJSONMarshalIndent
|
||||
oldMarshal := tokenJSONMarshal
|
||||
oldMkdir := tokenMkdirAll
|
||||
oldRead := tokenReadFile
|
||||
oldWrite := tokenWriteFile
|
||||
oldRename := tokenRename
|
||||
oldRemove := tokenRemove
|
||||
oldGlob := tokenGlob
|
||||
oldSaveCorp := tokenSaveKeychainForCorpID
|
||||
oldSaveIdentity := tokenSaveKeychainForIdentity
|
||||
oldSaveLegacy := tokenSaveKeychain
|
||||
oldLoadCorp := tokenLoadKeychainForCorpID
|
||||
oldLoadIdentity := tokenLoadKeychainIdentity
|
||||
oldLoadLegacy := tokenLoadKeychain
|
||||
oldExists := tokenKeychainExists
|
||||
oldDeleteCorp := tokenDeleteKeychainForCorpID
|
||||
oldDeleteIdentity := tokenDeleteKeychainIdentity
|
||||
oldDeleteLegacy := tokenDeleteKeychain
|
||||
oldRemoveAuthEntries := tokenRemoveAuthTokenEntries
|
||||
oldLoadSecure := tokenLoadSecure
|
||||
oldDeleteSecure := tokenDeleteSecure
|
||||
oldEnsureProfiles := profilesEnsureMigration
|
||||
oldResolve := tokenResolveProfile
|
||||
oldResolveDeletion := tokenResolveDeletion
|
||||
oldResolveSelection := tokenResolveSelection
|
||||
oldUpsert := tokenUpsertProfile
|
||||
oldRemoveProfile := tokenRemoveProfile
|
||||
oldSync := tokenSyncLegacyMirror
|
||||
oldSyncOrganization := tokenSyncOrganizationMirror
|
||||
oldLoadProfiles := tokenLoadProfiles
|
||||
oldSaveProfiles := tokenSaveProfiles
|
||||
oldWriteMarker := tokenWriteMarker
|
||||
oldWriteManualMarker := tokenWriteManualMarker
|
||||
oldDeleteMarker := tokenDeleteMarker
|
||||
oldParseURL := tokenParseURL
|
||||
oldNewRequest := tokenNewRequest
|
||||
oldDefaultDir := tokenDefaultConfigDir
|
||||
oldLoadData := tokenLoadData
|
||||
oldRevokeURL := tokenRevokeURL
|
||||
oldMCPBaseURL := tokenMCPBaseURL
|
||||
oldLogoutURL := tokenLogoutURL
|
||||
oldLogoutContinue := tokenLogoutContinueURL
|
||||
oldLogoutClient := tokenLogoutHTTPClient
|
||||
@@ -1477,31 +1501,43 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
tokenJSONMarshalIndent = oldMarshalIndent
|
||||
tokenJSONMarshal = oldMarshal
|
||||
tokenMkdirAll = oldMkdir
|
||||
tokenReadFile = oldRead
|
||||
tokenWriteFile = oldWrite
|
||||
tokenRename = oldRename
|
||||
tokenRemove = oldRemove
|
||||
tokenGlob = oldGlob
|
||||
tokenSaveKeychainForCorpID = oldSaveCorp
|
||||
tokenSaveKeychainForIdentity = oldSaveIdentity
|
||||
tokenSaveKeychain = oldSaveLegacy
|
||||
tokenLoadKeychainForCorpID = oldLoadCorp
|
||||
tokenLoadKeychainIdentity = oldLoadIdentity
|
||||
tokenLoadKeychain = oldLoadLegacy
|
||||
tokenKeychainExists = oldExists
|
||||
tokenDeleteKeychainForCorpID = oldDeleteCorp
|
||||
tokenDeleteKeychainIdentity = oldDeleteIdentity
|
||||
tokenDeleteKeychain = oldDeleteLegacy
|
||||
tokenRemoveAuthTokenEntries = oldRemoveAuthEntries
|
||||
tokenLoadSecure = oldLoadSecure
|
||||
tokenDeleteSecure = oldDeleteSecure
|
||||
profilesEnsureMigration = oldEnsureProfiles
|
||||
tokenResolveProfile = oldResolve
|
||||
tokenResolveDeletion = oldResolveDeletion
|
||||
tokenResolveSelection = oldResolveSelection
|
||||
tokenUpsertProfile = oldUpsert
|
||||
tokenRemoveProfile = oldRemoveProfile
|
||||
tokenSyncLegacyMirror = oldSync
|
||||
tokenSyncOrganizationMirror = oldSyncOrganization
|
||||
tokenLoadProfiles = oldLoadProfiles
|
||||
tokenSaveProfiles = oldSaveProfiles
|
||||
tokenWriteMarker = oldWriteMarker
|
||||
tokenWriteManualMarker = oldWriteManualMarker
|
||||
tokenDeleteMarker = oldDeleteMarker
|
||||
tokenParseURL = oldParseURL
|
||||
tokenNewRequest = oldNewRequest
|
||||
tokenDefaultConfigDir = oldDefaultDir
|
||||
tokenLoadData = oldLoadData
|
||||
tokenRevokeURL = oldRevokeURL
|
||||
tokenMCPBaseURL = oldMCPBaseURL
|
||||
tokenLogoutURL = oldLogoutURL
|
||||
tokenLogoutContinueURL = oldLogoutContinue
|
||||
tokenLogoutHTTPClient = oldLogoutClient
|
||||
@@ -1566,10 +1602,12 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Fatalf("corp marker error = %v", err)
|
||||
}
|
||||
SetRuntimeProfile("")
|
||||
tokenWriteManualMarker = func(string) error { return fail }
|
||||
if err := saveTokenDataLocked(dir, &TokenData{}); !errors.Is(err, fail) {
|
||||
t.Fatalf("legacy marker error = %v", err)
|
||||
}
|
||||
tokenWriteMarker = oldWriteMarker
|
||||
tokenWriteManualMarker = oldWriteManualMarker
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "coverage", SaveToken: func(string, []byte) error { return nil }})
|
||||
if err := saveTokenDataLocked(dir, data); err != nil {
|
||||
@@ -1624,6 +1662,9 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Fatalf("failed migration load = %#v %v", got, err)
|
||||
}
|
||||
tokenSaveKeychain = func(*TokenData) error { return nil }
|
||||
tokenLoadKeychain = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
tokenReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
tokenWriteManualMarker = func(string) error { return nil }
|
||||
deleted := false
|
||||
tokenDeleteSecure = func(string) error { deleted = true; return nil }
|
||||
if _, err := LoadTokenDataForProfile(dir, ""); err != nil || !deleted {
|
||||
@@ -1647,25 +1688,70 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Run("delete branches", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fail := errors.New("fail")
|
||||
selected := &Profile{CorpID: "corp"}
|
||||
tokenResolveProfile = func(string, string) (*Profile, error) { return nil, fail }
|
||||
selected := &Profile{CorpID: "corp", UserID: "user"}
|
||||
profilesEnsureMigration = func(string) error { return fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete migration error = %v", err)
|
||||
}
|
||||
profilesEnsureMigration = func(string) error { return nil }
|
||||
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return nil, fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete profiles load error = %v", err)
|
||||
}
|
||||
cfg := &ProfilesConfig{
|
||||
CurrentProfile: ProfileSelector(*selected),
|
||||
OrgCurrentProfiles: map[string]string{"corp": ProfileSelector(*selected)},
|
||||
Profiles: []Profile{*selected},
|
||||
}
|
||||
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return cfg, nil }
|
||||
tokenResolveDeletion = func(*ProfilesConfig, string) (*Profile, bool, error) { return nil, false, fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete resolution error = %v", err)
|
||||
}
|
||||
tokenResolveProfile = func(string, string) (*Profile, error) { return selected, nil }
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return fail }
|
||||
tokenResolveDeletion = func(*ProfilesConfig, string) (*Profile, bool, error) { return selected, true, nil }
|
||||
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: "corp", UserID: "user"}, nil
|
||||
}
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: "corp", UserID: "user"}, nil
|
||||
}
|
||||
tokenLoadKeychain = func() (*TokenData, error) {
|
||||
return &TokenData{CorpID: "corp", UserID: "user"}, nil
|
||||
}
|
||||
tokenReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
tokenSaveProfiles = func(string, *ProfilesConfig) error { return nil }
|
||||
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil }
|
||||
tokenSaveKeychainForCorpID = func(string, *TokenData) error { return nil }
|
||||
tokenSaveKeychain = func(*TokenData) error { return nil }
|
||||
tokenWriteMarker = func(string) error { return nil }
|
||||
tokenDeleteMarker = func(string) error { return nil }
|
||||
tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
|
||||
tokenSyncLegacyMirror = func(string) error { return nil }
|
||||
tokenSyncOrganizationMirror = func(Profile) error { return nil }
|
||||
tokenDeleteSecure = func(string) error { return nil }
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return nil }
|
||||
tokenDeleteKeychainIdentity = func(string, string) error { return fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete identity keychain error = %v", err)
|
||||
}
|
||||
tokenDeleteKeychainIdentity = func(string, string) error { return nil }
|
||||
cfg.OrgCurrentProfiles = map[string]string{"corp": ProfileSelector(*selected)}
|
||||
tokenRemoveProfile = func(string, string) (*Profile, error) {
|
||||
cfg.OrgCurrentProfiles = nil
|
||||
return selected, nil
|
||||
}
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete corp keychain error = %v", err)
|
||||
}
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return nil }
|
||||
cfg.OrgCurrentProfiles = map[string]string{"corp": ProfileSelector(*selected)}
|
||||
tokenRemoveProfile = func(string, string) (*Profile, error) { return nil, fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("remove profile error = %v", err)
|
||||
}
|
||||
tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
|
||||
cfg.OrgCurrentProfiles = nil
|
||||
tokenSyncLegacyMirror = func(string) error { return fail }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
t.Fatalf("sync mirror error = %v", err)
|
||||
@@ -1676,7 +1762,8 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Fatalf("secure cleanup error = %v", err)
|
||||
}
|
||||
|
||||
tokenResolveProfile = func(string, string) (*Profile, error) { return nil, nil }
|
||||
cfg.CurrentProfile = ""
|
||||
cfg.Profiles = nil
|
||||
tokenDeleteKeychain = func() error { return fail }
|
||||
tokenDeleteMarker = func(string) error { return nil }
|
||||
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
|
||||
@@ -1704,22 +1791,17 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Run("delete all", func(t *testing.T) {
|
||||
fail := errors.New("fail")
|
||||
base := func() string {
|
||||
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return nil }
|
||||
tokenRemoveAuthTokenEntries = func(string) error { return nil }
|
||||
tokenRemove = func(string) error { return os.ErrNotExist }
|
||||
tokenGlob = func(string) ([]string, error) { return nil, nil }
|
||||
tokenDeleteKeychain = func() error { return nil }
|
||||
tokenDeleteSecure = func(string) error { return nil }
|
||||
tokenDeleteMarker = func(string) error { return nil }
|
||||
return t.TempDir()
|
||||
}
|
||||
dir := base()
|
||||
tokenLoadProfiles = func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Profiles: []Profile{{CorpID: "corp"}}}, nil
|
||||
}
|
||||
tokenDeleteKeychainForCorpID = func(string) error { return fail }
|
||||
tokenRemoveAuthTokenEntries = func(string) error { return fail }
|
||||
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete-all corp error = %v", err)
|
||||
t.Fatalf("delete-all auth namespace error = %v", err)
|
||||
}
|
||||
dir = base()
|
||||
tokenRemove = func(string) error { return fail }
|
||||
@@ -1739,11 +1821,6 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
t.Fatalf("delete-all quarantine error = %v", err)
|
||||
}
|
||||
dir = base()
|
||||
tokenDeleteKeychain = func() error { return fail }
|
||||
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete-all legacy keychain error = %v", err)
|
||||
}
|
||||
dir = base()
|
||||
tokenDeleteSecure = func(string) error { return fail }
|
||||
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
|
||||
t.Fatalf("delete-all secure error = %v", err)
|
||||
@@ -1759,6 +1836,9 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
resetClientIDFromMCP()
|
||||
SetClientID("client")
|
||||
fail := errors.New("fail")
|
||||
tokenLoadData = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "access", ClientID: "client", Source: "direct"}, nil
|
||||
}
|
||||
tokenParseURL = func(string) (*url.URL, error) { return nil, fail }
|
||||
if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
|
||||
t.Fatalf("logout parse error = %v", err)
|
||||
@@ -1791,16 +1871,14 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
|
||||
}
|
||||
|
||||
SetClientIDFromMCP("mcp-client")
|
||||
tokenRevokeURL = func() string { return "" }
|
||||
if err := RevokeTokenRemote(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenRevokeURL = func() string { return "https://revoke.test" }
|
||||
tokenLoadData = func(string) (*TokenData, error) { return nil, fail }
|
||||
if err := RevokeTokenRemote(context.Background()); err != nil {
|
||||
t.Fatal("missing token revoke should be a no-op")
|
||||
}
|
||||
tokenLoadData = func(string) (*TokenData, error) { return &TokenData{AccessToken: "access"}, nil }
|
||||
tokenMCPBaseURL = func() string { return "https://revoke.test" }
|
||||
tokenLoadData = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "access", ClientID: "mcp-client", Source: "mcp"}, nil
|
||||
}
|
||||
tokenJSONMarshal = func(any) ([]byte, error) { return nil, fail }
|
||||
if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
|
||||
t.Fatalf("revoke marshal error = %v", err)
|
||||
@@ -2201,8 +2279,8 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
|
||||
}
|
||||
primaryOnly := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{Name: "primary", CorpID: "primary"}}}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return primaryOnly, nil }
|
||||
if got, err := ResolveProfile(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
|
||||
t.Fatalf("primary profile resolution = %#v %v", got, err)
|
||||
if got, err := ResolveProfile(dir, ""); err != nil || got != nil {
|
||||
t.Fatalf("primary-only profile resolution = %#v %v", got, err)
|
||||
}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
|
||||
if got, err := ResolveProfile(dir, ""); err != nil || got != nil {
|
||||
@@ -2216,8 +2294,8 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
|
||||
}
|
||||
cfgNoCurrent := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{CorpID: "primary"}}}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return cfgNoCurrent, nil }
|
||||
if got, err := resolveProfileForLoad(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
|
||||
t.Fatalf("profile-for-load primary fallback = %#v %v", got, err)
|
||||
if got, err := resolveProfileForLoad(dir, ""); err != nil || got != nil {
|
||||
t.Fatalf("profile-for-load primary-only result = %#v %v", got, err)
|
||||
}
|
||||
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
|
||||
@@ -2236,6 +2314,10 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
|
||||
Profiles: []Profile{{Name: "primary", CorpID: "primary"}, {Name: "current", CorpID: "current"}, {Name: "previous", CorpID: "previous"}},
|
||||
}
|
||||
profilesSave = func(string, *ProfilesConfig) error { return nil }
|
||||
profilesLoadCorp = func(corpID string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: corpID, AccessToken: "x"}, nil
|
||||
}
|
||||
profilesSaveCorp = func(string, *TokenData) error { return nil }
|
||||
profilesSyncLegacyMirror = func(string) error { return fail }
|
||||
if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
|
||||
t.Fatalf("set-current mirror error = %v", err)
|
||||
@@ -2263,9 +2345,13 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
|
||||
deletedLegacy, deletedMarker := false, false
|
||||
profilesDeleteLegacy = func() error { deletedLegacy = true; return fail }
|
||||
profilesDeleteMarker = func(string) error { deletedMarker = true; return fail }
|
||||
if err := syncLegacyTokenMirrorLocked(dir); err != nil || !deletedLegacy || !deletedMarker {
|
||||
if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) || !deletedLegacy || deletedMarker {
|
||||
t.Fatalf("empty mirror cleanup = %v %v %v", err, deletedLegacy, deletedMarker)
|
||||
}
|
||||
profilesDeleteLegacy = func() error { return nil }
|
||||
if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) || !deletedMarker {
|
||||
t.Fatalf("empty mirror marker cleanup = %v %v", err, deletedMarker)
|
||||
}
|
||||
|
||||
normalizeProfilesConfig(nil)
|
||||
}
|
||||
@@ -2905,3 +2991,175 @@ func TestCrossPlatformCoverageDeviceFlowHighLevelCoverageEdges(t *testing.T) {
|
||||
}
|
||||
dfPrintBox(io.Discard, []string{"short"})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Profiles: []Profile{
|
||||
{Name: "local-a", CorpID: "corp-a", CorpName: "Shared", UserID: "u1", UserName: "Alice"},
|
||||
{Name: "duplicate", CorpID: "corp-a", CorpName: "Shared", UserID: "u2", UserName: "Alice"},
|
||||
{Name: "duplicate", CorpID: "corp-b", CorpName: "Shared", UserID: "u3", UserName: "Bob"},
|
||||
{Name: "solo", CorpID: "corp-c", CorpName: "Solo", UserID: "u4", UserName: "Carol"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
selector string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", true},
|
||||
{"missing:u1", true},
|
||||
{"Shared:u1", true},
|
||||
{"corp-a:u1", false},
|
||||
{"corp-a:Alice", true},
|
||||
{"corp-a:missing", true},
|
||||
{"corp-a", true},
|
||||
{"corp-c", false},
|
||||
{"Solo", false},
|
||||
{"local-a", false},
|
||||
{"duplicate", true},
|
||||
{"missing", true},
|
||||
} {
|
||||
_, _, err := resolveProfileSelection("", cfg, tc.selector)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("resolveProfileSelection(%q) error = %v", tc.selector, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := resolveProfileSelection("", nil, "x"); err == nil {
|
||||
t.Fatal("nil profile config selection succeeded")
|
||||
}
|
||||
cfg.OrgCurrentProfiles = map[string]string{"corp-a": "corp-a:u2"}
|
||||
if got, _, err := resolveProfileSelection("", cfg, "corp-a"); err != nil || got.UserID != "u2" {
|
||||
t.Fatalf("organization current selection = %#v %v", got, err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
selector string
|
||||
wantErr bool
|
||||
exact bool
|
||||
}{
|
||||
{"", true, false},
|
||||
{"corp-a:u1", false, true},
|
||||
{"corp-a", false, false},
|
||||
{"Solo", false, false},
|
||||
{"local-a", false, true},
|
||||
{"duplicate", true, true},
|
||||
{"missing", true, false},
|
||||
} {
|
||||
_, exact, err := resolveProfileDeletionSelection(cfg, tc.selector)
|
||||
if (err != nil) != tc.wantErr || (!tc.wantErr && exact != tc.exact) {
|
||||
t.Fatalf("resolveProfileDeletionSelection(%q) = exact %v, %v", tc.selector, exact, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := resolveProfileDeletionSelection(nil, "x"); err == nil {
|
||||
t.Fatal("nil deletion config succeeded")
|
||||
}
|
||||
|
||||
for _, selector := range []string{"", "corp-a", "missing", "Solo", "Shared"} {
|
||||
_, _ = resolveOrganizationCorpID(cfg, selector)
|
||||
}
|
||||
if got, err := resolveOrganizationCorpID(nil, "x"); err != nil || got != "" {
|
||||
t.Fatalf("nil organization resolution = %q %v", got, err)
|
||||
}
|
||||
if _, _, err := resolveOrganizationDefault(cfg, "missing", "missing", nil); err == nil {
|
||||
t.Fatal("empty organization default succeeded")
|
||||
}
|
||||
if got, _, err := resolveOrganizationDefault(cfg, "corp-c", "Solo", profilesForCorpID(cfg, "corp-c")); err != nil || got.UserID != "u4" {
|
||||
t.Fatalf("single account organization default = %#v %v", got, err)
|
||||
}
|
||||
if _, _, err := resolveOrganizationDefault(&ProfilesConfig{}, "corp-a", "corp-a", profilesForCorpID(cfg, "corp-a")); err == nil {
|
||||
t.Fatal("ambiguous organization default succeeded")
|
||||
}
|
||||
if got := profileSelectorCandidates([]*Profile{nil, &cfg.Profiles[1], &cfg.Profiles[0]}); len(got) != 2 || got[0] != "corp-a:u1" {
|
||||
t.Fatalf("selector candidates = %#v", got)
|
||||
}
|
||||
for selector, want := range map[string]bool{
|
||||
"": false,
|
||||
"corp-a:u1": true,
|
||||
"Shared:Bob": true,
|
||||
"corp-c": true,
|
||||
"local-a": true,
|
||||
"definitely-missing": false,
|
||||
} {
|
||||
if got := profileSelectorReferenceExists(cfg, selector); got != want {
|
||||
t.Fatalf("profileSelectorReferenceExists(%q) = %v", selector, got)
|
||||
}
|
||||
}
|
||||
if profileSelectorReferenceExists(nil, "corp-a") {
|
||||
t.Fatal("nil profile selector reference exists")
|
||||
}
|
||||
|
||||
oldAcquire := profilesAcquireDualLock
|
||||
oldEnsure := profilesEnsureMigration
|
||||
oldLoad := profilesLoad
|
||||
t.Cleanup(func() {
|
||||
profilesAcquireDualLock = oldAcquire
|
||||
profilesEnsureMigration = oldEnsure
|
||||
profilesLoad = oldLoad
|
||||
})
|
||||
profilesAcquireDualLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
||||
profilesEnsureMigration = func(string) error { return nil }
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
|
||||
if got, exact, err := ResolveProfileDeletionScope("cfg", "corp-a:u1"); err != nil || !exact || got.UserID != "u1" {
|
||||
t.Fatalf("deletion scope = %#v %v %v", got, exact, err)
|
||||
}
|
||||
profilesEnsureMigration = func(string) error { return errors.New("migration") }
|
||||
if _, _, err := ResolveProfileDeletionScope("cfg", "corp-a"); err == nil {
|
||||
t.Fatal("deletion scope migration failure succeeded")
|
||||
}
|
||||
profilesEnsureMigration = func(string) error { return nil }
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return nil, errors.New("load") }
|
||||
if _, _, err := ResolveProfileDeletionScope("cfg", "corp-a"); err == nil {
|
||||
t.Fatal("deletion scope load failure succeeded")
|
||||
}
|
||||
|
||||
oldLoadIdentity := profilesLoadIdentity
|
||||
oldLoadCorp := profilesLoadCorp
|
||||
oldSaveIdentity := profilesSaveIdentity
|
||||
t.Cleanup(func() {
|
||||
profilesLoadIdentity = oldLoadIdentity
|
||||
profilesLoadCorp = oldLoadCorp
|
||||
profilesSaveIdentity = oldSaveIdentity
|
||||
})
|
||||
profile := Profile{CorpID: "corp-a", UserID: "u1"}
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return &TokenData{AccessToken: "identity"}, nil }
|
||||
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "identity" {
|
||||
t.Fatalf("identity token load = %#v %v", got, err)
|
||||
}
|
||||
fail := errors.New("fail")
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, fail }
|
||||
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
|
||||
t.Fatalf("identity load failure = %v", err)
|
||||
}
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("missing organization mirror = %v", err)
|
||||
}
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, fail }
|
||||
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
|
||||
t.Fatalf("organization mirror failure = %v", err)
|
||||
}
|
||||
for _, data := range []*TokenData{
|
||||
{CorpID: "corp-a"},
|
||||
{CorpID: "corp-a", UserID: "other"},
|
||||
} {
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return data, nil }
|
||||
if _, err := loadTokenForProfileIdentity(profile); err == nil {
|
||||
t.Fatalf("invalid organization mirror %#v succeeded", data)
|
||||
}
|
||||
}
|
||||
profilesLoadCorp = func(string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: "corp-a", UserID: "u1", AccessToken: "mirror"}, nil
|
||||
}
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return fail }
|
||||
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
|
||||
t.Fatalf("identity repair save failure = %v", err)
|
||||
}
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
|
||||
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" {
|
||||
t.Fatalf("identity repair = %#v %v", got, err)
|
||||
}
|
||||
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil {
|
||||
t.Fatalf("organization-only token load = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,15 +75,16 @@ var (
|
||||
)
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
terminalBaseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
terminalBaseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -267,9 +268,10 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0)
|
||||
|
||||
oauthProvider := &OAuthProvider{
|
||||
configDir: p.configDir,
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
configDir: p.configDir,
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
IdentityEnricher: p.IdentityEnricher,
|
||||
}
|
||||
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
||||
if err != nil {
|
||||
@@ -352,6 +354,9 @@ 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 := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -52,6 +53,14 @@ func TokenAccountForCorpID(corpID string) string {
|
||||
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
|
||||
}
|
||||
|
||||
// TokenAccountForIdentity returns the stable keychain account used for one
|
||||
// DingTalk identity. The hash avoids collisions caused by delimiter escaping
|
||||
// or keychain/file-name restrictions.
|
||||
func TokenAccountForIdentity(corpID, userID string) string {
|
||||
identity := strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
|
||||
return fmt.Sprintf("%s:id:%x", keychain.AccountToken, sha256.Sum256([]byte(identity)))
|
||||
}
|
||||
|
||||
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
|
||||
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
@@ -61,6 +70,16 @@ func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
|
||||
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
|
||||
}
|
||||
|
||||
// SaveTokenDataKeychainForIdentity saves TokenData to an identity-scoped slot.
|
||||
func SaveTokenDataKeychainForIdentity(corpID, userID string, data *TokenData) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if corpID == "" || userID == "" {
|
||||
return fmt.Errorf("corpId and userId are required for identity token storage")
|
||||
}
|
||||
return saveTokenDataKeychainAccount(TokenAccountForIdentity(corpID, userID), data)
|
||||
}
|
||||
|
||||
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
|
||||
jsonData, err := authKeychainMarshal(data, "", " ")
|
||||
if err != nil {
|
||||
@@ -93,6 +112,16 @@ func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
|
||||
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// LoadTokenDataKeychainForIdentity loads TokenData from an identity-scoped slot.
|
||||
func LoadTokenDataKeychainForIdentity(corpID, userID string) (*TokenData, error) {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if corpID == "" || userID == "" {
|
||||
return nil, fmt.Errorf("corpId and userId are required for identity token storage")
|
||||
}
|
||||
return loadTokenDataKeychainAccount(TokenAccountForIdentity(corpID, userID))
|
||||
}
|
||||
|
||||
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
jsonStr, err := authKeychainGet(keychain.Service, account)
|
||||
if err != nil {
|
||||
@@ -127,6 +156,9 @@ func preflightTokenPersistence(configDir string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
if err := ensureProfilesWritable(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
// LoadProfiles normalizes away blank and duplicate corp IDs.
|
||||
corpID := profile.CorpID
|
||||
@@ -137,6 +169,19 @@ func preflightTokenPersistence(configDir string) error {
|
||||
)
|
||||
}
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
if corpID == "" || userID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"identity token slot %q is unreadable; remove only this account with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
|
||||
TokenAccountForIdentity(corpID, userID), ProfileSelector(profile), err,
|
||||
)
|
||||
}
|
||||
}
|
||||
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",
|
||||
@@ -149,10 +194,17 @@ func preflightTokenPersistence(configDir string) error {
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProfilesWritable(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
@@ -161,8 +213,22 @@ func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
userID := strings.TrimSpace(data.UserID)
|
||||
if userID != "" {
|
||||
if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err)
|
||||
}
|
||||
}
|
||||
checkOrganizationMirror := true
|
||||
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
|
||||
checkOrganizationMirror =
|
||||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
|
||||
profileSelector(corpID, userID)
|
||||
}
|
||||
if checkOrganizationMirror {
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -181,6 +247,16 @@ func DeleteTokenDataKeychainForCorpID(corpID string) error {
|
||||
return authKeychainRemove(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychainForIdentity removes one identity-scoped token.
|
||||
func DeleteTokenDataKeychainForIdentity(corpID, userID string) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if corpID == "" || userID == "" {
|
||||
return fmt.Errorf("corpId and userId are required for identity token storage")
|
||||
}
|
||||
return authKeychainRemove(keychain.Service, TokenAccountForIdentity(corpID, userID))
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychain checks if token data exists in keychain.
|
||||
func TokenDataExistsKeychain() bool {
|
||||
return authKeychainExists(keychain.Service, keychain.AccountToken)
|
||||
@@ -195,6 +271,16 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
|
||||
return authKeychainExists(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychainForIdentity checks if an identity-scoped token exists.
|
||||
func TokenDataExistsKeychainForIdentity(corpID, userID string) bool {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if corpID == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
return authKeychainExists(keychain.Service, TokenAccountForIdentity(corpID, userID))
|
||||
}
|
||||
|
||||
// EnsureMigration performs one-time migration from legacy .data to keychain.
|
||||
// This should be called early in the auth flow (e.g., during GetAccessToken).
|
||||
// The migration is idempotent and thread-safe.
|
||||
|
||||
@@ -293,6 +293,8 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
CorpName string `json:"corpName"`
|
||||
CorpNameSnake string `json:"corp_name"`
|
||||
OrgName string `json:"orgName"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
// Error fields (when request fails)
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
@@ -320,6 +322,8 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
||||
CorpID: resp.CorpID,
|
||||
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
|
||||
UserID: resp.UserID,
|
||||
UserName: resp.UserName,
|
||||
}
|
||||
if resp.PersistentCode != "" {
|
||||
data.PersistentCode = resp.PersistentCode
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
)
|
||||
|
||||
// oauthHTTPClient is a dedicated HTTP client for OAuth operations with
|
||||
@@ -44,6 +45,7 @@ var (
|
||||
oauthPollInterval = 5 * time.Second
|
||||
oauthSuccessPause = 2 * time.Second
|
||||
oauthLoadToken = LoadTokenData
|
||||
oauthLoadTokenLocked = loadTokenDataForProfileLocked
|
||||
oauthAcquireLock = AcquireDualLock
|
||||
oauthMarkProfile = MarkProfileStatus
|
||||
oauthFetchClientID = FetchClientIDFromMCP
|
||||
@@ -73,6 +75,9 @@ type OAuthProvider struct {
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
TargetCorpID string
|
||||
// IdentityEnricher resolves userId/userName/corpName while the freshly
|
||||
// exchanged access token is still only in memory.
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -175,6 +180,14 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.flow.start",
|
||||
"client_id", strings.TrimSpace(p.clientID),
|
||||
"target_corp_id", strings.TrimSpace(p.TargetCorpID),
|
||||
"callback_port", port,
|
||||
"force", force,
|
||||
"no_browser", p.NoBrowser,
|
||||
)
|
||||
|
||||
// Channel to pass callback result (token data or error with CLI auth status)
|
||||
type callbackResult struct {
|
||||
@@ -205,6 +218,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if code == "" {
|
||||
code = r.URL.Query().Get("code")
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.callback.received",
|
||||
"callback_port", port,
|
||||
"has_authorization_code", code != "",
|
||||
)
|
||||
|
||||
// Check state and handle page refresh or concurrent requests
|
||||
callbackTokenMu.Lock()
|
||||
@@ -264,6 +282,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
// Exchange code for token
|
||||
tokenData, exchangeErr := oauthExchange(p, ctx, code)
|
||||
if exchangeErr != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.token_exchange.error",
|
||||
"callback_port", port,
|
||||
"error", exchangeErr,
|
||||
)
|
||||
// Clear in-progress state on error
|
||||
callbackTokenMu.Lock()
|
||||
if callbackCodeInProgress == code {
|
||||
@@ -279,6 +302,16 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
return
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.token_exchange.done",
|
||||
"callback_port", port,
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"user_name", strings.TrimSpace(tokenData.UserName),
|
||||
"source", strings.TrimSpace(tokenData.Source),
|
||||
"access_expires_at", tokenData.ExpiresAt,
|
||||
"refresh_expires_at", tokenData.RefreshExpAt,
|
||||
)
|
||||
|
||||
// Mark as processed immediately after successful exchange
|
||||
callbackTokenMu.Lock()
|
||||
@@ -302,6 +335,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
}
|
||||
cliAuthEnabled := denialReason == ""
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.organization_access.checked",
|
||||
"callback_port", port,
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"enabled", cliAuthEnabled,
|
||||
"denial_reason", denialReason,
|
||||
)
|
||||
|
||||
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
|
||||
// the terminal so portal can update copy without releasing the CLI.
|
||||
@@ -555,9 +595,30 @@ continueLogin:
|
||||
|
||||
// Save token data with associated client ID for refresh
|
||||
tokenData.ClientID = p.clientID
|
||||
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.persistence.start",
|
||||
"callback_port", port,
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"user_name", strings.TrimSpace(tokenData.UserName),
|
||||
)
|
||||
if err := p.persistLoginToken(ctx, tokenData); err != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.persistence.error",
|
||||
"callback_port", port,
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"error", err,
|
||||
)
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.persistence.done",
|
||||
"callback_port", port,
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"user_name", strings.TrimSpace(tokenData.UserName),
|
||||
)
|
||||
|
||||
// Persist app credentials (with secret) if using custom client credentials.
|
||||
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
||||
@@ -595,12 +656,13 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
if rErr == nil {
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
||||
} else {
|
||||
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
||||
@@ -635,7 +697,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 := oauthLoadToken(p.configDir)
|
||||
data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -654,7 +716,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("refresh_token 已过期")
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(data); err != nil {
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
@@ -672,14 +734,71 @@ func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid stri
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
||||
}
|
||||
if uid != "" {
|
||||
tokenData.UserID = uid
|
||||
tokenData.UserID = strings.TrimSpace(uid)
|
||||
if err := p.persistKnownLoginToken(tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
|
||||
if err := p.persistLoginToken(ctx, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenData) error {
|
||||
corpID, userID, userName := "", "", ""
|
||||
if tokenData != nil {
|
||||
corpID = strings.TrimSpace(tokenData.CorpID)
|
||||
userID = strings.TrimSpace(tokenData.UserID)
|
||||
userName = strings.TrimSpace(tokenData.UserName)
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.identity.before_enrich",
|
||||
"corp_id", corpID,
|
||||
"user_id", userID,
|
||||
"user_name", userName,
|
||||
)
|
||||
if err := p.prepareLoginToken(ctx, tokenData); err != nil {
|
||||
return err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.login.oauth.identity.after_enrich",
|
||||
"corp_id", strings.TrimSpace(tokenData.CorpID),
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"user_name", strings.TrimSpace(tokenData.UserName),
|
||||
)
|
||||
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenData) error {
|
||||
if tokenData == nil {
|
||||
return fmt.Errorf("token data is empty")
|
||||
}
|
||||
if p != nil && p.IdentityEnricher != nil {
|
||||
if err := p.IdentityEnricher(ctx, tokenData); err != nil {
|
||||
return fmt.Errorf("resolve login identity: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
||||
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error {
|
||||
if tokenData == nil {
|
||||
return fmt.Errorf("token data is empty")
|
||||
}
|
||||
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
||||
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
|
||||
}
|
||||
return oauthSaveToken(p.configDir, tokenData)
|
||||
}
|
||||
|
||||
// Logout clears all stored credentials.
|
||||
func (p *OAuthProvider) Logout() error {
|
||||
return DeleteTokenData(p.configDir)
|
||||
|
||||
@@ -641,6 +641,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
||||
oldLoad := oauthLoadToken
|
||||
oldLoadLocked := oauthLoadTokenLocked
|
||||
oldAcquire := oauthAcquireLock
|
||||
oldMark := oauthMarkProfile
|
||||
oldFetch := oauthFetchClientID
|
||||
@@ -651,6 +652,7 @@ func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
||||
oldSaveLocked := oauthSaveTokenLocked
|
||||
t.Cleanup(func() {
|
||||
oauthLoadToken = oldLoad
|
||||
oauthLoadTokenLocked = oldLoadLocked
|
||||
oauthAcquireLock = oldAcquire
|
||||
oauthMarkProfile = oldMark
|
||||
oauthFetchClientID = oldFetch
|
||||
@@ -665,6 +667,9 @@ func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
||||
})
|
||||
fail := errors.New("fail")
|
||||
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default(), Output: io.Discard}
|
||||
oauthLoadTokenLocked = func(configDir, _ string) (*TokenData, error) {
|
||||
return oauthLoadToken(configDir)
|
||||
}
|
||||
|
||||
valid := &TokenData{AccessToken: "valid", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
|
||||
|
||||
@@ -69,13 +69,44 @@ type PortableImportReport struct {
|
||||
OSMismatch bool
|
||||
}
|
||||
|
||||
// PortableExportSupported reports whether the current platform can produce a
|
||||
// bundle that includes the file-based DEK required for import elsewhere.
|
||||
// PortableExportSupported reports whether the current credential backend can
|
||||
// produce a bundle that includes the file-based DEK required for import
|
||||
// elsewhere.
|
||||
func PortableExportSupported() bool {
|
||||
if portableRuntimeGOOS() != "darwin" {
|
||||
return true
|
||||
return PortableExportSupportError() == nil
|
||||
}
|
||||
|
||||
// PortableExportSupportError explains why the current credential backend
|
||||
// cannot produce a portable auth bundle. A nil error means export is
|
||||
// supported.
|
||||
func PortableExportSupportError() error {
|
||||
return portableExportSupportError(portableRuntimeGOOS(), os.Getenv(keychain.DisableKeychainEnv))
|
||||
}
|
||||
|
||||
func portableExportSupportError(goos, disableKeychain string) error {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return fmt.Errorf("portable auth export is not supported on Windows because credentials are stored as DPAPI-protected HKCU Registry values; a portable conversion is not implemented")
|
||||
case "darwin":
|
||||
if disableKeychain == "" {
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
}
|
||||
}
|
||||
return os.Getenv(keychain.DisableKeychainEnv) != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// PortableImportSupportError explains why the current credential backend
|
||||
// cannot consume a portable auth bundle. A nil error means import is
|
||||
// supported.
|
||||
func PortableImportSupportError() error {
|
||||
return portableImportSupportError(portableRuntimeGOOS())
|
||||
}
|
||||
|
||||
func portableImportSupportError(goos string) error {
|
||||
if goos == "windows" {
|
||||
return fmt.Errorf("portable auth import is not supported on Windows because file-DEK credentials cannot be represented as DPAPI-protected HKCU Registry values; a portable conversion is not implemented")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PortableAuthTargetPopulated reports whether local auth files would be
|
||||
@@ -128,8 +159,8 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
if err := PortableExportSupportError(); err != nil {
|
||||
return err
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := portableStat(keychainDir); err != nil {
|
||||
@@ -180,6 +211,9 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
if r == nil {
|
||||
return PortableImportReport{}, fmt.Errorf("missing input reader")
|
||||
}
|
||||
if err := PortableImportSupportError(); err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err)
|
||||
|
||||
@@ -14,30 +14,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestPortableExportSupported(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true on non-darwin")
|
||||
func TestCrossPlatformCoveragePortableExportSupported(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be false for the Windows DPAPI backend")
|
||||
}
|
||||
var bundle bytes.Buffer
|
||||
err := ExportPortableAuthBundle(t.TempDir(), &bundle)
|
||||
if err == nil || !strings.Contains(err.Error(), "DPAPI") {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes on unsupported Windows backend", bundle.Len())
|
||||
}
|
||||
return
|
||||
case "darwin":
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
if PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
||||
}
|
||||
return
|
||||
default:
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true for file-DEK platforms")
|
||||
}
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
if PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortableExportSupportError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
disableKeychain string
|
||||
wantError string
|
||||
}{
|
||||
{name: "windows dpapi", goos: "windows", disableKeychain: "1", wantError: "DPAPI"},
|
||||
{name: "darwin keychain", goos: "darwin", wantError: keychain.DisableKeychainEnv},
|
||||
{name: "darwin file dek", goos: "darwin", disableKeychain: "1"},
|
||||
{name: "linux file dek", goos: "linux"},
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := portableExportSupportError(test.goos, test.disableKeychain)
|
||||
if test.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("portableExportSupportError() error = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("portableExportSupportError() error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortableImportSupportError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
wantError string
|
||||
}{
|
||||
{name: "windows dpapi", goos: "windows", wantError: "DPAPI"},
|
||||
{name: "darwin", goos: "darwin"},
|
||||
{name: "linux", goos: "linux"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := portableImportSupportError(test.goos)
|
||||
if test.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("portableImportSupportError() error = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("portableImportSupportError() error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
keychainDir := filepath.Join(root, "keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
bundle := validPortableAuthBundleForTest(t)
|
||||
|
||||
if _, err := ImportPortableAuthBundle(configDir, bytes.NewReader(bundle)); err == nil || !strings.Contains(err.Error(), "DPAPI") {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
|
||||
}
|
||||
for _, path := range []string{configDir, keychainDir} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +288,7 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() after import error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profiles after import = %#v", cfg)
|
||||
}
|
||||
if len(cfg.Profiles) != 2 {
|
||||
@@ -215,9 +311,106 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesSameCorpAccounts(t *testing.T) {
|
||||
requirePortableFileBackend(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
first := &TokenData{
|
||||
AccessToken: "access-first",
|
||||
RefreshToken: "refresh-first",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_same",
|
||||
CorpName: "Same Org",
|
||||
UserID: "user_1",
|
||||
UserName: "账号一",
|
||||
}
|
||||
second := *first
|
||||
second.AccessToken = "access-second"
|
||||
second.RefreshToken = "refresh-second"
|
||||
second.UserID = "user_2"
|
||||
second.UserName = "账号二"
|
||||
if err := SaveTokenData(sourceConfig, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, &second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
for selector, wantToken := range map[string]string{
|
||||
"corp_same:user_1": "access-first",
|
||||
"corp_same:user_2": "access-second",
|
||||
"corp_same": "access-second",
|
||||
} {
|
||||
loaded, err := LoadTokenDataForProfile(targetConfig, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(%s) error = %v", selector, err)
|
||||
}
|
||||
if loaded.AccessToken != wantToken {
|
||||
t.Fatalf("profile %s token = %q, want %q", selector, loaded.AccessToken, wantToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requirePortableFileBackend(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage")
|
||||
}
|
||||
}
|
||||
|
||||
func validPortableAuthBundleForTest(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
var bundle bytes.Buffer
|
||||
gz := gzip.NewWriter(&bundle)
|
||||
tw := tar.NewWriter(gz)
|
||||
manifest := portableAuthBundleManifest{
|
||||
Version: 1,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
OS: "linux",
|
||||
KeychainService: keychain.Service,
|
||||
ConfigFiles: []string{"app.json"},
|
||||
}
|
||||
if err := writePortableManifest(tw, manifest); err != nil {
|
||||
t.Fatalf("writePortableManifest() error = %v", err)
|
||||
}
|
||||
entries := map[string]string{
|
||||
path.Join("keychain", keychain.Service, keychain.AccountToken+".enc"): "encrypted-token",
|
||||
"config/app.json": "{}",
|
||||
}
|
||||
for name, content := range entries {
|
||||
hdr := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatalf("WriteHeader(%s) error = %v", name, err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("Write(%s) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar.Close() error = %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("gzip.Close() error = %v", err)
|
||||
}
|
||||
return bundle.Bytes()
|
||||
}
|
||||
|
||||
+1011
-208
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
|
||||
if err := preflightTokenPersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(&TokenData{CorpID: "corp"}); err != nil {
|
||||
if err := preflightTokenRefreshPersistence(t.TempDir(), &TokenData{CorpID: "corp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
|
||||
if err := preflightTokenPersistence(dir); !errors.Is(err, fail) {
|
||||
t.Fatalf("profile slot error = %v", err)
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(&TokenData{CorpID: "corp"}); !errors.Is(err, fail) {
|
||||
if err := preflightTokenRefreshPersistence(t.TempDir(), &TokenData{CorpID: "corp"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("refresh profile slot error = %v", err)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
|
||||
origGOOS := portableRuntimeGOOS
|
||||
origStat := portableStat
|
||||
origConfigFiles := portableConfigFilesForExport
|
||||
origManifest := portableWriteManifest
|
||||
@@ -99,6 +100,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
|
||||
origKeychainGet := authKeychainGet
|
||||
origKeychainExists := authKeychainExists
|
||||
t.Cleanup(func() {
|
||||
portableRuntimeGOOS = origGOOS
|
||||
portableStat = origStat
|
||||
portableConfigFilesForExport = origConfigFiles
|
||||
portableWriteManifest = origManifest
|
||||
@@ -107,6 +109,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
|
||||
authKeychainGet = origKeychainGet
|
||||
authKeychainExists = origKeychainExists
|
||||
})
|
||||
portableRuntimeGOOS = func() string { return "linux" }
|
||||
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
|
||||
+685
-69
@@ -28,6 +28,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -35,35 +37,49 @@ var (
|
||||
tokenJSONMarshalIndent = json.MarshalIndent
|
||||
tokenJSONMarshal = json.Marshal
|
||||
tokenMkdirAll = os.MkdirAll
|
||||
tokenReadFile = os.ReadFile
|
||||
tokenWriteFile = os.WriteFile
|
||||
tokenRename = os.Rename
|
||||
tokenRemove = os.Remove
|
||||
tokenGlob = filepath.Glob
|
||||
tokenSaveKeychainForCorpID = SaveTokenDataKeychainForCorpID
|
||||
tokenSaveKeychainForIdentity = SaveTokenDataKeychainForIdentity
|
||||
tokenSaveKeychain = SaveTokenDataKeychain
|
||||
tokenLoadKeychainForCorpID = LoadTokenDataKeychainForCorpID
|
||||
tokenLoadKeychainIdentity = LoadTokenDataKeychainForIdentity
|
||||
tokenLoadKeychain = LoadTokenDataKeychain
|
||||
tokenKeychainExists = TokenDataExistsKeychain
|
||||
tokenDeleteKeychainForCorpID = DeleteTokenDataKeychainForCorpID
|
||||
tokenDeleteKeychainIdentity = DeleteTokenDataKeychainForIdentity
|
||||
tokenDeleteKeychain = DeleteTokenDataKeychain
|
||||
tokenRemoveAuthTokenEntries = keychain.RemoveAuthTokenEntries
|
||||
tokenLoadSecure = LoadSecureTokenData
|
||||
tokenDeleteSecure = DeleteSecureData
|
||||
tokenResolveProfile = resolveProfileForLoad
|
||||
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
|
||||
tokenRemoveProfile = removeProfileLocked
|
||||
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
||||
tokenLoadProfiles = LoadProfiles
|
||||
tokenWriteMarker = WriteTokenMarker
|
||||
tokenDeleteMarker = DeleteTokenMarker
|
||||
tokenParseURL = url.Parse
|
||||
tokenNewRequest = http.NewRequestWithContext
|
||||
tokenDefaultConfigDir = getDefaultConfigDir
|
||||
tokenLoadData = LoadTokenData
|
||||
tokenRevokeURL = GetRevokeTokenURL
|
||||
tokenLogoutURL = LogoutURL
|
||||
tokenLogoutContinueURL = LogoutContinueURL
|
||||
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
tokenResolveProfile = func(configDir, selector string) (*Profile, error) {
|
||||
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
|
||||
return profile, err
|
||||
}
|
||||
tokenResolveDeletion = resolveProfileDeletionSelection
|
||||
tokenResolveSelection = resolveProfileSelection
|
||||
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
|
||||
tokenRemoveProfile = removeProfileLocked
|
||||
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
||||
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
|
||||
tokenLoadProfiles = LoadProfiles
|
||||
tokenSaveProfiles = SaveProfiles
|
||||
tokenWriteMarker = WriteTokenMarker
|
||||
tokenWriteManualMarker = WriteManualTokenMarker
|
||||
tokenDeleteMarker = DeleteTokenMarker
|
||||
tokenParseURL = url.Parse
|
||||
tokenNewRequest = http.NewRequestWithContext
|
||||
tokenDefaultConfigDir = getDefaultConfigDir
|
||||
tokenLoadData = LoadTokenData
|
||||
tokenRevokeURL = GetRevokeTokenURL
|
||||
tokenMCPBaseURL = GetMCPBaseURL
|
||||
tokenLogoutURL = LogoutURL
|
||||
tokenLogoutContinueURL = LogoutContinueURL
|
||||
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
)
|
||||
|
||||
// TokenData holds the OAuth token set persisted to disk.
|
||||
@@ -109,14 +125,29 @@ const tokenJSONFile = "token.json"
|
||||
// TokenMarker is a lightweight file the host application reads to detect
|
||||
// whether the CLI has a valid token without accessing the keychain.
|
||||
type TokenMarker struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ManualToken bool `json:"manual_token,omitempty"`
|
||||
}
|
||||
|
||||
// WriteTokenMarker writes a token.json marker containing only an updated_at
|
||||
// timestamp. The host application uses this file's presence and mtime to
|
||||
// decide whether it needs to trigger a new auth exchange.
|
||||
func WriteTokenMarker(configDir string) error {
|
||||
marker := TokenMarker{UpdatedAt: time.Now().Format(time.RFC3339)}
|
||||
return writeTokenMarker(configDir, false)
|
||||
}
|
||||
|
||||
// WriteManualTokenMarker marks the legacy global keychain slot as an explicit
|
||||
// `auth login --token` credential. The additive field keeps older hosts, which
|
||||
// only inspect token.json presence and mtime, fully compatible.
|
||||
func WriteManualTokenMarker(configDir string) error {
|
||||
return writeTokenMarker(configDir, true)
|
||||
}
|
||||
|
||||
func writeTokenMarker(configDir string, manual bool) error {
|
||||
marker := TokenMarker{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
ManualToken: manual,
|
||||
}
|
||||
data, _ := tokenJSONMarshalIndent(marker, "", " ")
|
||||
if err := tokenMkdirAll(configDir, 0o700); err != nil {
|
||||
return err
|
||||
@@ -128,6 +159,23 @@ func WriteTokenMarker(configDir string) error {
|
||||
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
|
||||
}
|
||||
|
||||
func manualTokenMarkerActive(configDir string) (bool, error) {
|
||||
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
var marker TokenMarker
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
// Historical hosts only require the marker's presence. A malformed old
|
||||
// marker must not make profile authentication unusable.
|
||||
return false, nil
|
||||
}
|
||||
return marker.ManualToken, nil
|
||||
}
|
||||
|
||||
// DeleteTokenMarker removes the token.json marker file.
|
||||
func DeleteTokenMarker(configDir string) error {
|
||||
if err := tokenRemove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
|
||||
@@ -158,26 +206,126 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
if data != nil && strings.TrimSpace(data.CorpID) != "" {
|
||||
if err := tokenSaveKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
userID := strings.TrimSpace(data.UserID)
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
|
||||
if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
|
||||
if err := ensureProfilesWritable(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeSelector := strings.TrimSpace(RuntimeProfile())
|
||||
makeCurrent := runtimeSelector == ""
|
||||
exactSelector := profileSelector(corpID, userID)
|
||||
mirrorOrg := makeCurrent ||
|
||||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector
|
||||
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
|
||||
upgradesLegacyProfile := !existingIdentity && userID != "" && legacyProfileIndexByCorpID(cfg, corpID) >= 0
|
||||
logging.AuthDebug(
|
||||
"auth.token.persist.plan",
|
||||
"corp_id", corpID,
|
||||
"user_id", userID,
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"identity_selector", exactSelector,
|
||||
"existing_identity", existingIdentity,
|
||||
"upgrades_legacy_profile", upgradesLegacyProfile,
|
||||
"profiles_before", len(cfg.Profiles),
|
||||
"runtime_profile", runtimeSelector,
|
||||
"write_identity_slot", userID != "",
|
||||
"write_org_mirror", mirrorOrg,
|
||||
"write_global_mirror", makeCurrent,
|
||||
)
|
||||
snapshot, err := snapshotTokenPersistence(configDir, cfg, corpID, userID, mirrorOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preserveManualDefault := !makeCurrent &&
|
||||
snapshot.marker.known &&
|
||||
snapshot.marker.exists &&
|
||||
snapshot.marker.manual
|
||||
rollback := func(operationErr error) error {
|
||||
if rollbackErr := restoreTokenPersistence(configDir, snapshot); rollbackErr != nil {
|
||||
return errors.Join(operationErr, fmt.Errorf("rollback token persistence: %w", rollbackErr))
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
if userID != "" {
|
||||
if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) != "" {
|
||||
return fmt.Errorf("cannot store profile for corpId %q without userId because account identities already exist", corpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if mirrorOrg {
|
||||
if err := tokenSaveKeychainForCorpID(corpID, data); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
if makeCurrent {
|
||||
if err := tokenSaveKeychain(data); err != nil {
|
||||
return err
|
||||
return rollback(err)
|
||||
}
|
||||
} else if !preserveManualDefault {
|
||||
if err := tokenSyncLegacyMirror(configDir); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if err := tokenSyncLegacyMirror(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return tokenWriteMarker(configDir)
|
||||
if preserveManualDefault {
|
||||
if err := tokenWriteManualMarker(configDir); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if err := tokenWriteMarker(configDir); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.token.persist.done",
|
||||
"corp_id", corpID,
|
||||
"user_id", userID,
|
||||
"user_name", strings.TrimSpace(data.UserName),
|
||||
"identity_selector", exactSelector,
|
||||
"write_identity_slot", userID != "",
|
||||
"write_org_mirror", mirrorOrg,
|
||||
"write_global_mirror", makeCurrent,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
legacySnapshot, err := snapshotTokenSlot(tokenLoadKeychain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markerSnapshot, err := snapshotTokenMarker(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tokenSaveKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return tokenWriteMarker(configDir)
|
||||
if err := tokenWriteManualMarker(configDir); err != nil {
|
||||
var rollbackErr error
|
||||
if restoreErr := restoreTokenSlot(
|
||||
legacySnapshot,
|
||||
tokenSaveKeychain,
|
||||
tokenDeleteKeychain,
|
||||
); restoreErr != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, restoreErr)
|
||||
}
|
||||
if restoreErr := restoreTokenMarker(configDir, markerSnapshot); restoreErr != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, restoreErr)
|
||||
}
|
||||
if rollbackErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("rollback manual token persistence: %w", rollbackErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
|
||||
@@ -213,13 +361,38 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
return &td, nil
|
||||
}
|
||||
|
||||
var result *TokenData
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
var loadErr error
|
||||
result, loadErr = loadTokenDataForProfileLocked(configDir, profile)
|
||||
return loadErr
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error) {
|
||||
// Default: keychain with legacy .data migration
|
||||
if strings.TrimSpace(profile) == "" {
|
||||
manual, err := manualTokenMarkerActive(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manual {
|
||||
data, loadErr := tokenLoadKeychain()
|
||||
if loadErr == nil && data != nil && strings.TrimSpace(data.CorpID) == "" {
|
||||
return data, nil
|
||||
}
|
||||
if loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) {
|
||||
return nil, loadErr
|
||||
}
|
||||
}
|
||||
}
|
||||
selected, err := tokenResolveProfile(configDir, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected != nil {
|
||||
data, err := tokenLoadKeychainForCorpID(selected.CorpID)
|
||||
data, err := tokenLoadProfileIdentity(*selected)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
@@ -231,13 +404,21 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
// the SAME org; otherwise surface the error instead of silently acting
|
||||
// as a different organization (the legacy mirror may have drifted).
|
||||
if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) &&
|
||||
(strings.TrimSpace(selected.UserID) == "" || strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID)) {
|
||||
return legacy, nil
|
||||
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
|
||||
return nil, lerr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg != nil && cfg.Version >= profilesVersion {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if tokenKeychainExists() {
|
||||
return tokenLoadKeychain()
|
||||
}
|
||||
@@ -253,6 +434,36 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
return tokenLoadKeychainForCorpID(profile.CorpID)
|
||||
}
|
||||
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
orgData, orgErr := tokenLoadKeychainForCorpID(profile.CorpID)
|
||||
if orgErr != nil {
|
||||
if errors.Is(orgErr, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, orgErr
|
||||
}
|
||||
if strings.TrimSpace(orgData.UserID) == "" {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
|
||||
}
|
||||
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
|
||||
return nil, err
|
||||
}
|
||||
if saveErr := tokenSaveKeychainForIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
return orgData, nil
|
||||
}
|
||||
|
||||
// DeleteTokenData removes token data. When an edition hook (DeleteToken) is
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain + legacy cleanup.
|
||||
@@ -275,25 +486,114 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
|
||||
}
|
||||
|
||||
func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
selected, err := tokenResolveProfile(configDir, profile)
|
||||
if strings.TrimSpace(profile) == "" {
|
||||
manual, err := manualTokenMarkerActive(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if manual {
|
||||
return deleteManualTokenDataLocked(configDir)
|
||||
}
|
||||
}
|
||||
if err := profilesEnsureMigration(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
effectiveSelector := strings.TrimSpace(profile)
|
||||
if effectiveSelector == "" {
|
||||
effectiveSelector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
}
|
||||
var selected *Profile
|
||||
exact := false
|
||||
if effectiveSelector != "" {
|
||||
selected, exact, err = tokenResolveDeletion(cfg, effectiveSelector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if selected != nil {
|
||||
keychainErr := tokenDeleteKeychainForCorpID(selected.CorpID)
|
||||
_, removeErr := tokenRemoveProfile(configDir, selected.CorpID)
|
||||
legacyErr := tokenSyncLegacyMirror(configDir)
|
||||
secureErr := tokenDeleteSecure(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
removed := *selected
|
||||
originalCfg := cloneProfilesConfig(cfg)
|
||||
identitySnapshots, err := snapshotDeletionIdentities(cfg, removed, exact)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if removeErr != nil {
|
||||
return removeErr
|
||||
orgSnapshot := snapshotTokenSlotForDeletion(func() (*TokenData, error) {
|
||||
return tokenLoadKeychainForCorpID(removed.CorpID)
|
||||
})
|
||||
legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain)
|
||||
markerSnapshot := snapshotTokenMarkerForDeletion(configDir)
|
||||
|
||||
// Clean the deprecated secure-store copy before changing the profile
|
||||
// transaction. A cleanup failure therefore leaves all current metadata
|
||||
// and keychain slots untouched.
|
||||
if err := tokenDeleteSecure(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
|
||||
removeSelector := removed.CorpID
|
||||
orgCurrent := false
|
||||
if exact {
|
||||
removeSelector = ProfileSelector(removed)
|
||||
orgCurrent = exactProfileSelectorForCorp(
|
||||
cfg,
|
||||
removed.CorpID,
|
||||
cfg.OrgCurrentProfiles[removed.CorpID],
|
||||
) == ProfileSelector(removed)
|
||||
}
|
||||
return secureErr
|
||||
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
|
||||
return err
|
||||
}
|
||||
rollback := func(operationErr error) error {
|
||||
if rollbackErr := restoreProfileDeletion(
|
||||
configDir,
|
||||
originalCfg,
|
||||
identitySnapshots,
|
||||
removed.CorpID,
|
||||
orgSnapshot,
|
||||
legacySnapshot,
|
||||
markerSnapshot,
|
||||
); rollbackErr != nil {
|
||||
return errors.Join(operationErr, fmt.Errorf("rollback profile deletion: %w", rollbackErr))
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
|
||||
if !exact || orgCurrent {
|
||||
updated, loadErr := tokenLoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return rollback(loadErr)
|
||||
}
|
||||
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
|
||||
if exact && replacementSelector != "" {
|
||||
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
|
||||
if resolveErr != nil {
|
||||
return rollback(resolveErr)
|
||||
}
|
||||
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
preserveManualDefault := markerSnapshot.known &&
|
||||
markerSnapshot.exists &&
|
||||
markerSnapshot.manual
|
||||
if !preserveManualDefault {
|
||||
if err := tokenSyncLegacyMirror(configDir); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
for _, snapshot := range identitySnapshots {
|
||||
if err := tokenDeleteKeychainIdentity(snapshot.profile.CorpID, snapshot.profile.UserID); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
keychainErr := tokenDeleteKeychain()
|
||||
@@ -308,6 +608,308 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
return markerErr
|
||||
}
|
||||
|
||||
func deleteManualTokenDataLocked(configDir string) error {
|
||||
legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain)
|
||||
if err := tokenDeleteSecure(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tokenDeleteKeychain(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tokenDeleteMarker(configDir); err != nil {
|
||||
if legacySnapshot.known {
|
||||
if rollbackErr := restoreTokenSlot(
|
||||
legacySnapshot,
|
||||
tokenSaveKeychain,
|
||||
tokenDeleteKeychain,
|
||||
); rollbackErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("rollback manual token deletion: %w", rollbackErr))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type deletionIdentitySnapshot struct {
|
||||
profile Profile
|
||||
token *TokenData
|
||||
}
|
||||
|
||||
type tokenSlotSnapshot struct {
|
||||
token *TokenData
|
||||
known bool
|
||||
exists bool
|
||||
}
|
||||
|
||||
type tokenMarkerSnapshot struct {
|
||||
known bool
|
||||
exists bool
|
||||
manual bool
|
||||
}
|
||||
|
||||
type tokenPersistenceSnapshot struct {
|
||||
profiles *ProfilesConfig
|
||||
corpID string
|
||||
userID string
|
||||
identity tokenSlotSnapshot
|
||||
org tokenSlotSnapshot
|
||||
legacy tokenSlotSnapshot
|
||||
marker tokenMarkerSnapshot
|
||||
}
|
||||
|
||||
func cloneProfilesConfig(cfg *ProfilesConfig) *ProfilesConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *cfg
|
||||
cloned.Profiles = append([]Profile(nil), cfg.Profiles...)
|
||||
for i := range cloned.Profiles {
|
||||
cloned.Profiles[i].AuthorizedDomains = append(
|
||||
[]string(nil),
|
||||
cloned.Profiles[i].AuthorizedDomains...,
|
||||
)
|
||||
}
|
||||
if cfg.OrgCurrentProfiles != nil {
|
||||
cloned.OrgCurrentProfiles = make(map[string]string, len(cfg.OrgCurrentProfiles))
|
||||
for corpID, selector := range cfg.OrgCurrentProfiles {
|
||||
cloned.OrgCurrentProfiles[corpID] = selector
|
||||
}
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func snapshotDeletionIdentities(cfg *ProfilesConfig, removed Profile, exact bool) ([]deletionIdentitySnapshot, error) {
|
||||
var profiles []Profile
|
||||
if exact {
|
||||
profiles = []Profile{removed}
|
||||
} else {
|
||||
for _, candidate := range cfg.Profiles {
|
||||
if strings.TrimSpace(candidate.CorpID) == strings.TrimSpace(removed.CorpID) {
|
||||
profiles = append(profiles, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshots := make([]deletionIdentitySnapshot, 0, len(profiles))
|
||||
for _, candidate := range profiles {
|
||||
if strings.TrimSpace(candidate.UserID) == "" {
|
||||
continue
|
||||
}
|
||||
data, err := tokenLoadKeychainIdentity(candidate.CorpID, candidate.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTokenDataNotFound) {
|
||||
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate})
|
||||
continue
|
||||
}
|
||||
// A damaged target slot must remain removable. It cannot be restored
|
||||
// during rollback, but every readable slot in the same transaction
|
||||
// still is.
|
||||
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate})
|
||||
continue
|
||||
}
|
||||
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate, token: data})
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func snapshotTokenSlot(load func() (*TokenData, error)) (tokenSlotSnapshot, error) {
|
||||
data, err := load()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTokenDataNotFound) {
|
||||
return tokenSlotSnapshot{known: true}, nil
|
||||
}
|
||||
return tokenSlotSnapshot{}, err
|
||||
}
|
||||
return tokenSlotSnapshot{token: data, known: true, exists: data != nil}, nil
|
||||
}
|
||||
|
||||
func snapshotTokenSlotForDeletion(load func() (*TokenData, error)) tokenSlotSnapshot {
|
||||
snapshot, err := snapshotTokenSlot(load)
|
||||
if err != nil {
|
||||
return tokenSlotSnapshot{}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func snapshotTokenMarker(configDir string) (tokenMarkerSnapshot, error) {
|
||||
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return tokenMarkerSnapshot{known: true}, nil
|
||||
}
|
||||
return tokenMarkerSnapshot{}, err
|
||||
}
|
||||
var marker TokenMarker
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return tokenMarkerSnapshot{known: true, exists: true}, nil
|
||||
}
|
||||
return tokenMarkerSnapshot{known: true, exists: true, manual: marker.ManualToken}, nil
|
||||
}
|
||||
|
||||
func snapshotTokenMarkerForDeletion(configDir string) tokenMarkerSnapshot {
|
||||
snapshot, err := snapshotTokenMarker(configDir)
|
||||
if err != nil {
|
||||
return tokenMarkerSnapshot{}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func restoreProfileDeletion(
|
||||
configDir string,
|
||||
cfg *ProfilesConfig,
|
||||
identities []deletionIdentitySnapshot,
|
||||
corpID string,
|
||||
org tokenSlotSnapshot,
|
||||
legacy tokenSlotSnapshot,
|
||||
marker tokenMarkerSnapshot,
|
||||
) error {
|
||||
var rollbackErr error
|
||||
for _, snapshot := range identities {
|
||||
if snapshot.token == nil {
|
||||
continue
|
||||
}
|
||||
if err := tokenSaveKeychainForIdentity(
|
||||
snapshot.profile.CorpID,
|
||||
snapshot.profile.UserID,
|
||||
snapshot.token,
|
||||
); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if err := tokenSaveProfiles(configDir, cloneProfilesConfig(cfg)); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
if org.known {
|
||||
if org.exists {
|
||||
if err := tokenSaveKeychainForCorpID(corpID, org.token); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
} else if err := tokenDeleteKeychainForCorpID(corpID); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if legacy.known {
|
||||
if legacy.exists {
|
||||
if err := tokenSaveKeychain(legacy.token); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
} else if err := tokenDeleteKeychain(); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if marker.known {
|
||||
if err := restoreTokenMarker(configDir, marker); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func snapshotTokenPersistence(
|
||||
configDir string,
|
||||
cfg *ProfilesConfig,
|
||||
corpID, userID string,
|
||||
includeOrganization bool,
|
||||
) (tokenPersistenceSnapshot, error) {
|
||||
snapshot := tokenPersistenceSnapshot{
|
||||
profiles: cloneProfilesConfig(cfg),
|
||||
corpID: corpID,
|
||||
userID: userID,
|
||||
}
|
||||
var err error
|
||||
if strings.TrimSpace(userID) != "" {
|
||||
snapshot.identity, err = snapshotTokenSlot(func() (*TokenData, error) {
|
||||
return tokenLoadKeychainIdentity(corpID, userID)
|
||||
})
|
||||
if err != nil {
|
||||
return tokenPersistenceSnapshot{}, err
|
||||
}
|
||||
}
|
||||
if includeOrganization {
|
||||
snapshot.org, err = snapshotTokenSlot(func() (*TokenData, error) {
|
||||
return tokenLoadKeychainForCorpID(corpID)
|
||||
})
|
||||
if err != nil {
|
||||
return tokenPersistenceSnapshot{}, err
|
||||
}
|
||||
}
|
||||
snapshot.legacy, err = snapshotTokenSlot(tokenLoadKeychain)
|
||||
if err != nil {
|
||||
return tokenPersistenceSnapshot{}, err
|
||||
}
|
||||
snapshot.marker, err = snapshotTokenMarker(configDir)
|
||||
if err != nil {
|
||||
return tokenPersistenceSnapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func restoreTokenPersistence(configDir string, snapshot tokenPersistenceSnapshot) error {
|
||||
var rollbackErr error
|
||||
if err := tokenSaveProfiles(configDir, cloneProfilesConfig(snapshot.profiles)); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.userID) != "" && snapshot.identity.known {
|
||||
if err := restoreTokenSlot(
|
||||
snapshot.identity,
|
||||
func(data *TokenData) error {
|
||||
return tokenSaveKeychainForIdentity(snapshot.corpID, snapshot.userID, data)
|
||||
},
|
||||
func() error {
|
||||
return tokenDeleteKeychainIdentity(snapshot.corpID, snapshot.userID)
|
||||
},
|
||||
); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if snapshot.org.known {
|
||||
if err := restoreTokenSlot(
|
||||
snapshot.org,
|
||||
func(data *TokenData) error {
|
||||
return tokenSaveKeychainForCorpID(snapshot.corpID, data)
|
||||
},
|
||||
func() error {
|
||||
return tokenDeleteKeychainForCorpID(snapshot.corpID)
|
||||
},
|
||||
); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if snapshot.legacy.known {
|
||||
if err := restoreTokenSlot(snapshot.legacy, tokenSaveKeychain, tokenDeleteKeychain); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
if snapshot.marker.known {
|
||||
if err := restoreTokenMarker(configDir, snapshot.marker); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func restoreTokenSlot(
|
||||
snapshot tokenSlotSnapshot,
|
||||
save func(*TokenData) error,
|
||||
remove func() error,
|
||||
) error {
|
||||
if snapshot.exists {
|
||||
return save(snapshot.token)
|
||||
}
|
||||
return remove()
|
||||
}
|
||||
|
||||
func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error {
|
||||
switch {
|
||||
case !marker.exists:
|
||||
return tokenDeleteMarker(configDir)
|
||||
case marker.manual:
|
||||
return tokenWriteManualMarker(configDir)
|
||||
default:
|
||||
return tokenWriteMarker(configDir)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||
func DeleteAllTokenData(configDir string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
@@ -315,14 +917,10 @@ func DeleteAllTokenData(configDir string) error {
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
var firstErr error
|
||||
// Best-effort: even if profiles.json is unreadable, still clear every
|
||||
// other slot so the user can always self-heal via auth reset / logout.
|
||||
if cfg, err := tokenLoadProfiles(configDir); err == nil {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if e := tokenDeleteKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
// Sweep the complete auth-token namespace so orphan identity slots that
|
||||
// are not present in profiles.json cannot survive reset/logout --all.
|
||||
if e := tokenRemoveAuthTokenEntries(keychain.Service); e != nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := tokenRemove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
@@ -335,15 +933,19 @@ func DeleteAllTokenData(configDir string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := tokenDeleteKeychain(); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := tokenDeleteSecure(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := tokenDeleteMarker(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if firstErr != nil {
|
||||
// Preserve an explicit v2 empty registry so any stale mirror that
|
||||
// could not be removed is never imported on a later read.
|
||||
if e := profilesSave(configDir, &ProfilesConfig{Version: profilesVersion}); e != nil {
|
||||
return fmt.Errorf("%v; save logged-out profile tombstone: %w", firstErr, e)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
})
|
||||
}
|
||||
@@ -353,18 +955,42 @@ func DeleteAllTokenData(configDir string) error {
|
||||
// This should be called before deleting local token data.
|
||||
// The function is best-effort: errors are returned but callers may choose to ignore them.
|
||||
func RevokeTokenRemote(ctx context.Context) error {
|
||||
// Use MCP revoke endpoint when clientID is from MCP
|
||||
if IsClientIDFromMCP() {
|
||||
return revokeTokenViaMCP(ctx)
|
||||
tokenData, err := tokenLoadData(tokenDefaultConfigDir())
|
||||
if err != nil || tokenData == nil {
|
||||
return nil
|
||||
}
|
||||
// Direct mode: use DingTalk logout endpoint
|
||||
// Historical token records may not have Source. Preserve the legacy
|
||||
// process-wide MCP decision only for those records.
|
||||
if strings.TrimSpace(tokenData.Source) == "" && IsClientIDFromMCP() {
|
||||
copy := *tokenData
|
||||
copy.Source = "mcp"
|
||||
tokenData = ©
|
||||
}
|
||||
return RevokeTokenRemoteForData(ctx, tokenData)
|
||||
}
|
||||
|
||||
// RevokeTokenRemoteForData revokes the supplied account token using the
|
||||
// credential source and client ID persisted with that exact identity.
|
||||
func RevokeTokenRemoteForData(ctx context.Context, tokenData *TokenData) error {
|
||||
if tokenData == nil {
|
||||
return nil
|
||||
}
|
||||
clientID := strings.TrimSpace(tokenData.ClientID)
|
||||
if clientID == "" {
|
||||
clientID = ClientID()
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(tokenData.Source), "mcp") {
|
||||
return revokeTokenViaMCP(ctx, tokenData, clientID)
|
||||
}
|
||||
|
||||
// Direct mode: use DingTalk logout endpoint.
|
||||
logoutURL, err := tokenParseURL(tokenLogoutURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing logout URL: %w", err)
|
||||
}
|
||||
|
||||
q := logoutURL.Query()
|
||||
q.Set("client_id", ClientID())
|
||||
q.Set("client_id", clientID)
|
||||
q.Set("continue", tokenLogoutContinueURL)
|
||||
logoutURL.RawQuery = q.Encode()
|
||||
|
||||
@@ -388,20 +1014,10 @@ func RevokeTokenRemote(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// revokeTokenViaMCP revokes token via MCP endpoint.
|
||||
func revokeTokenViaMCP(ctx context.Context) error {
|
||||
revokeURL := tokenRevokeURL()
|
||||
if revokeURL == "" {
|
||||
return nil // No revoke endpoint available
|
||||
}
|
||||
|
||||
// Load current token to get accessToken
|
||||
tokenData, err := tokenLoadData(tokenDefaultConfigDir())
|
||||
if err != nil || tokenData == nil {
|
||||
return nil // No token to revoke
|
||||
}
|
||||
|
||||
func revokeTokenViaMCP(ctx context.Context, tokenData *TokenData, clientID string) error {
|
||||
revokeURL := tokenMCPBaseURL() + MCPRevokeTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": ClientID(),
|
||||
"clientId": clientID,
|
||||
"accessToken": tokenData.AccessToken,
|
||||
}
|
||||
bodyBytes, err := tokenJSONMarshal(body)
|
||||
|
||||
@@ -18,6 +18,7 @@ package auth
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -92,7 +93,7 @@ func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
|
||||
func TestLoadTokenDataUsesIdentitySlotWhenOrganizationMirrorIsUnreadable(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
@@ -106,11 +107,11 @@ func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *test
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
|
||||
if loaded == nil || loaded.AccessToken != data.AccessToken || loaded.UserID != data.UserID {
|
||||
t.Fatalf("LoadTokenData() = %#v, want identity token %#v", loaded, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +151,65 @@ func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactOrgCurrentRefreshRejectsUnreadableOrgMirror(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_exact_refresh", "corp_exact", "Exact Org")
|
||||
data.UserID = "user_exact"
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp_exact:user_exact")
|
||||
defer SetRuntimeProfile("")
|
||||
if err := preflightTokenRefreshPersistence(configDir, data); err == nil ||
|
||||
!strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(exact current) error = %v, want unreadable org mirror", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
first := testToken("at_first", "corp_exact", "Exact Org")
|
||||
first.UserID = "user_first"
|
||||
second := testToken("at_second", "corp_exact", "Exact Org")
|
||||
second.UserID = "user_second"
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(first.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp_exact:user_first")
|
||||
defer SetRuntimeProfile("")
|
||||
if err := preflightTokenRefreshPersistence(configDir, first); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(exact non-current) error = %v", err)
|
||||
}
|
||||
updated := *first
|
||||
updated.AccessToken = "at_first_refreshed"
|
||||
updated.RefreshToken = "rt_first_refreshed"
|
||||
if err := SaveTokenData(configDir, &updated); err != nil {
|
||||
t.Fatalf("SaveTokenData(exact non-current refresh) error = %v", err)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(configDir, "corp_exact:user_first")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(refreshed) error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != updated.AccessToken || loaded.RefreshToken != updated.RefreshToken {
|
||||
t.Fatalf("refreshed exact token = %#v, want %#v", loaded, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
@@ -232,7 +292,7 @@ func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
if err := preflightTokenRefreshPersistence(dataB); err != nil {
|
||||
if err := preflightTokenRefreshPersistence(configDir, dataB); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
|
||||
}
|
||||
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
|
||||
@@ -343,6 +403,45 @@ func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_future_refresh", "corp_future", "Future Org")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
data.RefreshExpAt = time.Now().Add(time.Hour)
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.Version = profilesVersion + 1
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(ProfilesPath(configDir), raw, 0o600); err != nil {
|
||||
t.Fatalf("write future profiles: %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected refresh request")
|
||||
})}
|
||||
_, err = provider.Login(context.Background(), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
|
||||
t.Fatalf("Login() error = %v, want future profiles rejection", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("refresh HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
@@ -374,3 +473,37 @@ func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
t.Fatalf("HTTP calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeExplicitUIDSkipsIdentityOverride(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
var identityCalls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.IdentityEnricher = func(context.Context, *TokenData) error {
|
||||
identityCalls.Add(1)
|
||||
return errors.New("identity lookup should not run for explicit uid")
|
||||
}
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "explicit-user")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
||||
}
|
||||
if data.UserID != "explicit-user" {
|
||||
t.Fatalf("ExchangeAuthCode() userId = %q, want explicit-user", data.UserID)
|
||||
}
|
||||
if got := identityCalls.Load(); got != 0 {
|
||||
t.Fatalf("IdentityEnricher calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
+1721
-11
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
@@ -104,7 +105,8 @@
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -113,7 +115,8 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -227,7 +230,9 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -236,7 +241,9 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -262,7 +269,9 @@
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
]
|
||||
},
|
||||
"event list": {
|
||||
@@ -754,7 +763,7 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -763,7 +772,7 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -788,7 +797,7 @@
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"use_when": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
]
|
||||
},
|
||||
"event status": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"coverage": {
|
||||
"surface_products": 22,
|
||||
@@ -12,7 +12,7 @@
|
||||
"tools_with_avoid_when": 572,
|
||||
"tools_with_examples": 572,
|
||||
"tools_with_interface_mode": 572,
|
||||
"unmatched_skill_tools": 124,
|
||||
"unmatched_skill_tools": 120,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
"products": {
|
||||
@@ -870,20 +870,20 @@
|
||||
]
|
||||
},
|
||||
"event": {
|
||||
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
|
||||
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"avoid_when": [
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
@@ -910,7 +910,7 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -918,7 +918,7 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -938,7 +938,7 @@
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
},
|
||||
"live": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"source_files": 150,
|
||||
"hint_files": 46,
|
||||
@@ -36,7 +36,7 @@
|
||||
"tools_with_avoid_when": 572,
|
||||
"tools_with_examples": 572,
|
||||
"tools_with_interface_mode": 572,
|
||||
"unmatched_skill_tools": 124,
|
||||
"unmatched_skill_tools": 120,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
"source_products": [
|
||||
@@ -160,7 +160,7 @@
|
||||
{
|
||||
"tool_path": "calendar participant delete",
|
||||
"source": "skills/mono/SKILL.md",
|
||||
"line": 135,
|
||||
"line": 143,
|
||||
"candidates": [
|
||||
"calendar attendee delete",
|
||||
"calendar event delete",
|
||||
@@ -2866,62 +2866,6 @@
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "event schema user_im_message_receive_o2o",
|
||||
"source": "skills/mono/references/products/event.md",
|
||||
"line": 60,
|
||||
"candidates": [
|
||||
"event schema",
|
||||
"event consume",
|
||||
"event list"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "event consume user_im_message_receive_at -f ndjson",
|
||||
"source": "skills/mono/references/products/event.md",
|
||||
"line": 62,
|
||||
"candidates": [
|
||||
"event consume",
|
||||
"event list",
|
||||
"event schema"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "event consume user_im_message_receive_o2o",
|
||||
"source": "skills/mono/references/products/event.md",
|
||||
"line": 63,
|
||||
"candidates": [
|
||||
"event consume",
|
||||
"event list",
|
||||
"event schema"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "event consume user_im_message_receive_group",
|
||||
"source": "skills/mono/references/products/event.md",
|
||||
"line": 64,
|
||||
"candidates": [
|
||||
"event consume",
|
||||
"event list",
|
||||
"event schema"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "mail mailbox profile",
|
||||
"source": "skills/mono/references/products/mail.md",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:e4e7ed2cfc099d6e3893e9b059c0041c4ce0b8e103d52743e21b096d71564864",
|
||||
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 22,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"surface_products": 22,
|
||||
"surface_tools": 572,
|
||||
"tools_with_agent_summary": 572,
|
||||
"tools_with_metadata": 572,
|
||||
"unmatched_skill_tools": 124,
|
||||
"unmatched_skill_tools": 120,
|
||||
"version": 1
|
||||
},
|
||||
"count": 22,
|
||||
@@ -12113,7 +12113,7 @@
|
||||
"skills/mono/SKILL.md",
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
|
||||
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"avoid_when": [
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
|
||||
@@ -12126,13 +12126,13 @@
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
|
||||
},
|
||||
"avoid_when": {
|
||||
"candidates": [
|
||||
@@ -12159,7 +12159,7 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -12167,7 +12167,7 @@
|
||||
"resolution": "highest_precedence",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -12200,7 +12200,9 @@
|
||||
"risk": "medium",
|
||||
"title": "订阅事件流并输出到 stdout",
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -12254,7 +12256,7 @@
|
||||
"risk": "low",
|
||||
"title": "显示事件 schema",
|
||||
"use_when": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -12315,7 +12317,7 @@
|
||||
}
|
||||
],
|
||||
"use_when": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -290152,7 +290154,8 @@
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
@@ -290279,7 +290282,8 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -290288,7 +290292,8 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
@@ -290416,7 +290421,9 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -290425,7 +290432,9 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -290435,7 +290444,7 @@
|
||||
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
"is_alias": false,
|
||||
"name": "consume",
|
||||
"parameter_count": 26,
|
||||
"parameter_count": 27,
|
||||
"parameters": {
|
||||
"compact": {
|
||||
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
|
||||
@@ -291626,6 +291635,90 @@
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"open-dingtalk-id": {
|
||||
"description": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": true,
|
||||
"source": "flag_name_inference",
|
||||
"value": "openDingtalkId"
|
||||
}
|
||||
],
|
||||
"precedence": "inference",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "flag_name_inference",
|
||||
"value": "openDingtalkId"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"property": "openDingtalkId",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"output-dir": {
|
||||
"description": "每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥",
|
||||
"field_provenance": {
|
||||
@@ -292587,7 +292680,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "个人单聊对端 userId",
|
||||
"description": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
@@ -292595,7 +292688,7 @@
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "个人单聊对端 userId"
|
||||
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
@@ -292607,7 +292700,7 @@
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "个人单聊对端 userId"
|
||||
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
@@ -292688,7 +292781,9 @@
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "订阅事件流并输出到 stdout",
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
]
|
||||
},
|
||||
"event.list": {
|
||||
@@ -293649,7 +293744,7 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -293658,7 +293753,7 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -293779,7 +293874,7 @@
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "显示事件 schema",
|
||||
"use_when": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
]
|
||||
},
|
||||
"event.status": {
|
||||
|
||||
@@ -630,36 +630,104 @@
|
||||
"reason": "已审查的旧路径或跨产品文档前缀,固定映射到当前公开命令"
|
||||
},
|
||||
"event consume user_im_message_receive_at -f ndjson": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_group": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_o2o": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_user": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_read_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_read_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_recall_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_recall_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_reaction_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_reaction_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event schema user_im_message_receive_at": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_receive_group": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_receive_o2o": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event status": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"event schema user_im_message_receive_user": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event stop": {
|
||||
"status": "out_of_surface",
|
||||
"reason": "该产品不在当前 21 产品公开命令面中"
|
||||
"event schema user_im_message_read_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_read_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_recall_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_recall_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_reaction_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_reaction_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"mail aisearch person": {
|
||||
"status": "alias",
|
||||
|
||||
@@ -595,6 +595,41 @@
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_user": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_read_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_read_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_recall_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_recall_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_reaction_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_reaction_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event schema user_im_message_receive_at": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
@@ -610,6 +645,41 @@
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_receive_user": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_read_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_read_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_recall_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_recall_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_reaction_o2o": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"event schema user_im_message_reaction_group": {
|
||||
"status": "alias",
|
||||
"target": "event schema",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
|
||||
},
|
||||
"mail aisearch person": {
|
||||
"status": "alias",
|
||||
"target": "aisearch person",
|
||||
|
||||
@@ -11,14 +11,17 @@
|
||||
"event.consume": {
|
||||
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"只看事件目录/字段时用 event list / event schema"
|
||||
],
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
@@ -56,7 +59,7 @@
|
||||
"event.schema": {
|
||||
"agent_summary": "查询指定个人事件码的 payload 字段结构",
|
||||
"use_when": [
|
||||
"已知 event_key,消费前需要理解 payload 字段"
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
],
|
||||
"avoid_when": [
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
@@ -122,9 +125,9 @@
|
||||
},
|
||||
"products": {
|
||||
"event": {
|
||||
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
|
||||
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"use_when": [
|
||||
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
],
|
||||
"avoid_when": [
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
|
||||
|
||||
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
d.conns.Store("not-a-conn", struct{}{})
|
||||
d.conns.Store(&queryConn{}, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
d.shutdown()
|
||||
acceptDone := make(chan struct{})
|
||||
close(acceptDone)
|
||||
d.shutdown(acceptDone)
|
||||
d.consumerWG.Done()
|
||||
d.shutdown()
|
||||
d.shutdown(acceptDone)
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
// goroutines wake up, then close listener / drain consumers.
|
||||
// goroutines wake up, then stop accepting connections before draining
|
||||
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
|
||||
// sync.WaitGroup forbids a positive Add racing with Wait.
|
||||
cancelRun()
|
||||
d.shutdown()
|
||||
<-acceptDone
|
||||
d.shutdown(acceptDone)
|
||||
<-idleDone
|
||||
<-dropWarnDone
|
||||
|
||||
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
d.log.Warn("bus: accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
// Track the connection before publishing the handler goroutine. Once
|
||||
// acceptLoop returns, shutdown can therefore close every accepted
|
||||
// connection before waiting for handlers to drain.
|
||||
d.conns.Store(conn, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
go func() {
|
||||
defer d.consumerWG.Done()
|
||||
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
d.conns.Store(conn, struct{}{})
|
||||
defer func() {
|
||||
d.conns.Delete(conn)
|
||||
conn.Close()
|
||||
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
|
||||
// 1. mark shuttingDown so acceptLoop exits cleanly
|
||||
// 2. broadcast Bye to all consumers
|
||||
// 3. close listener (interrupts pending Accept)
|
||||
// 4. wait for all per-connection goroutines to drain
|
||||
// 5. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown() {
|
||||
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
|
||||
// 5. close all accepted connections and wait for handlers to drain
|
||||
// 6. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
|
||||
d.shutdownMu.Lock()
|
||||
defer d.shutdownMu.Unlock()
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
|
||||
}
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||
_ = d.listener.Close()
|
||||
<-acceptDone
|
||||
// Force-close all open IPC connections so any reader goroutine blocked
|
||||
// on Read() returns with a network error and exits cleanly. Without
|
||||
// this the consumerWG never drains and Run hangs forever.
|
||||
|
||||
@@ -16,6 +16,8 @@ package consume
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
@@ -49,8 +51,8 @@ const (
|
||||
// event, newline-terminated). Useful when piping into jq / a tool
|
||||
// that wants the cloud payload verbatim without our envelope.
|
||||
FormatRaw Format = "raw"
|
||||
// FormatCompact runs the per-event-type compact processor (see
|
||||
// registry.LookupProcessor) and emits one flattened JSON line.
|
||||
// FormatCompact emits one compact JSON line. Personal streams use their
|
||||
// configured business projection; other streams use the registry processor.
|
||||
FormatCompact Format = "compact"
|
||||
)
|
||||
|
||||
@@ -90,27 +92,103 @@ type Formatter interface {
|
||||
Render(ev transport.Event) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewFormatter returns a Formatter for the given Format. Compact wraps
|
||||
// registry.LookupProcessor so adding a new specialised compactor is just
|
||||
// a registry-side change. Returns an error only if format is an internally
|
||||
// unsupported value (defensive — NormalizeFormat guarantees the input is
|
||||
// one of the constants).
|
||||
func NewFormatter(format Format) (Formatter, error) {
|
||||
// Projector maps a transport envelope to the public value rendered by
|
||||
// structured formats. Returning a value together with an error means the
|
||||
// value is a safe fallback and should still be emitted after a warning.
|
||||
type Projector func(ev transport.Event) (any, error)
|
||||
|
||||
type formatterConfig struct {
|
||||
projector Projector
|
||||
warnings io.Writer
|
||||
warnOnce sync.Once
|
||||
}
|
||||
|
||||
// FormatterOption configures structured event rendering. Raw output always
|
||||
// bypasses these options and preserves the source Data string.
|
||||
type FormatterOption func(*formatterConfig)
|
||||
|
||||
func WithProjector(projector Projector) FormatterOption {
|
||||
return func(cfg *formatterConfig) { cfg.projector = projector }
|
||||
}
|
||||
|
||||
func WithProjectionWarnings(w io.Writer) FormatterOption {
|
||||
return func(cfg *formatterConfig) {
|
||||
if w != nil {
|
||||
cfg.warnings = w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewFormatter returns a Formatter for the given Format. When no projector
|
||||
// is configured, compact dispatches through registry.LookupProcessor. Returns
|
||||
// an error only if format is internally unsupported.
|
||||
func NewFormatter(format Format, opts ...FormatterOption) (Formatter, error) {
|
||||
cfg := &formatterConfig{warnings: io.Discard}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(cfg)
|
||||
}
|
||||
}
|
||||
switch format {
|
||||
case FormatNDJSON:
|
||||
return &ndjsonFormatter{}, nil
|
||||
return &structuredFormatter{config: cfg}, nil
|
||||
case FormatJSON, FormatPretty:
|
||||
return &prettyFormatter{}, nil
|
||||
return &structuredFormatter{config: cfg, pretty: true}, nil
|
||||
case FormatRaw:
|
||||
return &rawFormatter{}, nil
|
||||
case FormatCompact:
|
||||
return &compactFormatter{}, nil
|
||||
return &structuredFormatter{config: cfg, compact: true}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("consume: unsupported format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
// ndjsonFormatter encodes each Event as one compact JSON line + '\n'.
|
||||
// structuredFormatter renders either the transport envelope or a projected
|
||||
// business value. The same projector is used by ndjson/json/pretty/compact.
|
||||
type structuredFormatter struct {
|
||||
config *formatterConfig
|
||||
pretty bool
|
||||
compact bool
|
||||
}
|
||||
|
||||
func (f *structuredFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
value := any(ev)
|
||||
if f.config.projector != nil {
|
||||
projected, err := f.config.projector(ev)
|
||||
if projected != nil {
|
||||
value = projected
|
||||
}
|
||||
if err != nil {
|
||||
f.config.warnOnce.Do(func() {
|
||||
fmt.Fprintf(f.config.warnings,
|
||||
"WARN: personal event output projection failed; using raw envelope event_id=%q event_type=%q: %v\n",
|
||||
ev.EventID, ev.EventType, err)
|
||||
})
|
||||
}
|
||||
} else if f.compact {
|
||||
value = registry.LookupProcessor(ev.EventType)(ev)
|
||||
}
|
||||
var (
|
||||
b []byte
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case f.pretty:
|
||||
b, err = marshalEventIndent(value, "", " ")
|
||||
case f.compact:
|
||||
b, err = marshalCompact(value)
|
||||
default:
|
||||
b, err = marshalEvent(value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// The basic formatters remain available for focused encoder tests. The public
|
||||
// construction path uses structuredFormatter so personal event projections are
|
||||
// applied consistently across all structured formats.
|
||||
type ndjsonFormatter struct{}
|
||||
|
||||
func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
@@ -121,9 +199,6 @@ func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// prettyFormatter encodes each Event as multi-line indented JSON + '\n'.
|
||||
// json.MarshalIndent does not append a trailing newline; we add one so
|
||||
// successive events are visually separated in the output.
|
||||
type prettyFormatter struct{}
|
||||
|
||||
func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
@@ -134,6 +209,17 @@ func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
type compactFormatter struct{}
|
||||
|
||||
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
value := registry.LookupProcessor(ev.EventType)(ev)
|
||||
b, err := marshalCompact(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// rawFormatter writes ev.Data verbatim. If Data is already JSON it stays
|
||||
// JSON; if it's some other string format it stays that. A trailing newline
|
||||
// is appended so successive raw events are separable.
|
||||
@@ -147,17 +233,3 @@ func (rawFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compactFormatter dispatches to the registry per event_type and writes
|
||||
// the flattened map as one compact JSON line + '\n'.
|
||||
type compactFormatter struct{}
|
||||
|
||||
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
p := registry.LookupProcessor(ev.EventType)
|
||||
v := p(ev)
|
||||
b, err := marshalCompact(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -149,6 +151,84 @@ func TestCompactFormatter_DispatchesPerEventType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStructuredFormatsUseProjector(t *testing.T) {
|
||||
projector := Projector(func(ev transport.Event) (any, error) {
|
||||
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
|
||||
})
|
||||
for _, format := range []Format{FormatNDJSON, FormatJSON, FormatPretty, FormatCompact} {
|
||||
t.Run(string(format), func(t *testing.T) {
|
||||
f, err := NewFormatter(format, WithProjector(projector))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := f.Render(transport.Event{EventType: "personal.test", Data: `{"raw":true}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil {
|
||||
t.Fatalf("output is not JSON: %v", err)
|
||||
}
|
||||
if got["type"] != "personal.test" || got["content"] != "hello" {
|
||||
t.Fatalf("projected output = %#v", got)
|
||||
}
|
||||
if _, ok := got["data"]; ok {
|
||||
t.Fatalf("projected output leaked transport envelope: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectionFailureWarnsAndEmitsFallback(t *testing.T) {
|
||||
var warnings bytes.Buffer
|
||||
f, err := NewFormatter(
|
||||
FormatNDJSON,
|
||||
WithProjector(func(ev transport.Event) (any, error) { return ev, errors.New("bad payload") }),
|
||||
WithProjectionWarnings(&warnings),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := transport.Event{EventID: "outer", EventType: "user_im_message_receive_o2o", Data: "not-json"}
|
||||
for i := 0; i < 2; i++ {
|
||||
out, err := f.Render(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got transport.Event
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil || got.EventID != "outer" {
|
||||
t.Fatalf("fallback output = %s, err = %v", out, err)
|
||||
}
|
||||
}
|
||||
if count := strings.Count(warnings.String(), "WARN: personal event output projection failed"); count != 1 {
|
||||
t.Fatalf("warning count = %d, output = %q", count, warnings.String())
|
||||
}
|
||||
for _, want := range []string{`event_id="outer"`, `event_type="user_im_message_receive_o2o"`, "bad payload"} {
|
||||
if !strings.Contains(warnings.String(), want) {
|
||||
t.Fatalf("warning missing %q: %q", want, warnings.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRawFormatBypassesProjector(t *testing.T) {
|
||||
called := false
|
||||
raw := `{"payload":{"uid":100001,"clientId":"internal-client","filterSubId":"internal-filter","bizid":"internal-bizid"}}`
|
||||
f, err := NewFormatter(FormatRaw, WithProjector(func(transport.Event) (any, error) {
|
||||
called = true
|
||||
return map[string]any{"projected": true}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := f.Render(transport.Event{Data: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if called || string(out) != raw+"\n" {
|
||||
t.Fatalf("raw output = %q, projector called = %v", out, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormatter_RejectsUnknown(t *testing.T) {
|
||||
if _, err := NewFormatter(Format("nope")); err == nil {
|
||||
t.Fatal("expected error for unknown format")
|
||||
|
||||
@@ -65,8 +65,8 @@ func (p *Pipeline) Close() error { return p.sink.Close() }
|
||||
// stdoutW is injected for tests (os.Stdout in production). When nil it
|
||||
// defaults to io.Discard so a misconfigured pipeline never writes to
|
||||
// the host process's actual stdout.
|
||||
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer) (*Pipeline, error) {
|
||||
fmter, err := NewFormatter(format)
|
||||
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer, formatterOpts ...FormatterOption) (*Pipeline, error) {
|
||||
fmter, err := NewFormatter(format, formatterOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -62,6 +62,48 @@ func TestPipeline_OutputDirFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePipelineStdoutAndOutputDirUseSameProjection(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventType: "personal.message",
|
||||
EventID: "ev1",
|
||||
ReceivedAtUnixMS: 100,
|
||||
}
|
||||
projector := WithProjector(func(transport.Event) (any, error) {
|
||||
return map[string]any{"type": "personal.message", "content": "hello"}, nil
|
||||
})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
stdoutPipeline, err := BuildPipeline(FormatNDJSON, "", nil, &stdout, projector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stdoutPipeline.Close()
|
||||
if err := stdoutPipeline.Deliver(ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
filePipeline, err := BuildPipeline(FormatNDJSON, dir, nil, nil, projector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer filePipeline.Close()
|
||||
if err := filePipeline.Deliver(ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("ReadDir() entries = %d, err = %v", len(entries), err)
|
||||
}
|
||||
fileBody, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(stdout.Bytes(), fileBody) {
|
||||
t.Fatalf("stdout = %q, file = %q", stdout.Bytes(), fileBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_RouteWithStdoutFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
imDir := filepath.Join(root, "im")
|
||||
|
||||
@@ -95,6 +95,14 @@ type Config struct {
|
||||
OutputDir string
|
||||
// Routes are pre-parsed --route specs. Empty = no routing.
|
||||
Routes []Route
|
||||
// Projector, when set, maps transport envelopes to the public value used
|
||||
// by all structured formats. Raw format always bypasses it.
|
||||
Projector Projector
|
||||
|
||||
// ReadySubscribeID identifies the personal subscription in the stable
|
||||
// ready marker emitted after HelloAck. It is separate from SubscribeID
|
||||
// because debug raw mode deliberately clears the latter's local filter.
|
||||
ReadySubscribeID string
|
||||
|
||||
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
|
||||
// graceful shutdown (reason: signal). This wires the AI-subprocess
|
||||
@@ -175,7 +183,14 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
defer cancelRun()
|
||||
ctx = runCtx
|
||||
|
||||
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
|
||||
pipeline, err := BuildPipeline(
|
||||
cfg.Format,
|
||||
cfg.OutputDir,
|
||||
cfg.Routes,
|
||||
cfg.Stdout,
|
||||
WithProjector(cfg.Projector),
|
||||
WithProjectionWarnings(cfg.Stderr),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: build pipeline: %w", err)
|
||||
}
|
||||
@@ -221,7 +236,11 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
if cfg.EventKey != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d", cfg.EventKey, ack.BusPID)
|
||||
if cfg.ReadySubscribeID != "" {
|
||||
fmt.Fprintf(cfg.Stderr, " subscribe_id=%s", cfg.ReadySubscribeID)
|
||||
}
|
||||
fmt.Fprintln(cfg.Stderr)
|
||||
} else {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -139,13 +140,15 @@ func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer consumeCancel()
|
||||
err := Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
EventTypes: []string{"im.*"},
|
||||
MaxEvents: 2,
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
EventTypes: []string{"im.*"},
|
||||
EventKey: "im.message.receive_v1",
|
||||
ReadySubscribeID: "sub-1",
|
||||
MaxEvents: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -170,6 +173,10 @@ func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "[event] ready ") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1 bus_pid=") ||
|
||||
!strings.Contains(stderr.String(), " subscribe_id=sub-1") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_QuietSuppressesStderr(t *testing.T) {
|
||||
@@ -184,13 +191,15 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
|
||||
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer consumeCancel()
|
||||
err := Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Quiet: true,
|
||||
MaxEvents: 1,
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Quiet: true,
|
||||
EventKey: "im.message.receive_v1",
|
||||
ReadySubscribeID: "sub-1",
|
||||
MaxEvents: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -203,6 +212,78 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_WritesFirstProjectedEventBeforeConsumeExits(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancelBus, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "personal.message", Data: `{"content":"hello"}`},
|
||||
})
|
||||
defer func() { cancelBus(); <-runDone }()
|
||||
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
defer writer.Close()
|
||||
|
||||
ctx, cancelConsume := context.WithCancel(context.Background())
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: writer,
|
||||
Stderr: io.Discard,
|
||||
Projector: func(ev transport.Event) (any, error) {
|
||||
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
|
||||
},
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
}()
|
||||
|
||||
lineCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
line, err := bufio.NewReader(reader).ReadString('\n')
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
lineCh <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case line := <-lineCh:
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &got); err != nil || got["content"] != "hello" {
|
||||
t.Fatalf("first line = %q, err = %v", line, err)
|
||||
}
|
||||
case err := <-errCh:
|
||||
t.Fatalf("read first line: %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first event was not readable while consume was running")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
t.Fatalf("consume exited before cancellation: %v", err)
|
||||
default:
|
||||
}
|
||||
cancelConsume()
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Run() after cancellation = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("consume did not exit after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CtxCancelReturnsCleanly(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -71,7 +72,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
|
||||
RuleType: "singleChat",
|
||||
Name: "test-o2o",
|
||||
RuleParam: map[string]any{
|
||||
"targetUid": "507971",
|
||||
"targetUid": "test-user-001",
|
||||
"targetUidType": "staffId",
|
||||
},
|
||||
Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"},
|
||||
@@ -93,7 +94,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil {
|
||||
t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err)
|
||||
}
|
||||
if filterRule["targetUid"] != "507971" || filterRule["targetUidType"] != "staffId" {
|
||||
if filterRule["targetUid"] != "test-user-001" || filterRule["targetUidType"] != "staffId" {
|
||||
t.Fatalf("filterRule = %#v", filterRule)
|
||||
}
|
||||
if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" {
|
||||
@@ -107,6 +108,72 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventKey string
|
||||
opts RuleOptions
|
||||
wantRule map[string]any
|
||||
}{
|
||||
{"receive_o2o/staffId", EventSingleChat, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
|
||||
{"receive_o2o/openDingtalkId", EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"read_o2o/staffId", EventReadO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
|
||||
{"read_o2o/openDingtalkId", EventReadO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"recall_o2o/staffId", EventRecallO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
|
||||
{"recall_o2o/openDingtalkId", EventRecallO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"reaction_o2o/staffId", EventReactionO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
|
||||
{"reaction_o2o/openDingtalkId", EventReactionO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"receive_user/staffId", EventFromUser, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
|
||||
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotReq dwsCreateSubscriptionRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"sub-rule"}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ruleType, ruleParam, err := BuildRuleParam(tt.eventKey, tt.opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
if _, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: tt.eventKey,
|
||||
RuleType: ruleType,
|
||||
RuleParam: ruleParam,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if gotPath != "/subscription/user" || gotReq.EventKey != tt.eventKey {
|
||||
t.Fatalf("path = %q, eventKey = %q", gotPath, gotReq.EventKey)
|
||||
}
|
||||
var gotRule map[string]any
|
||||
if err := json.Unmarshal([]byte(gotReq.FilterRule), &gotRule); err != nil {
|
||||
t.Fatalf("filterRule = %q: %v", gotReq.FilterRule, err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRule, tt.wantRule) {
|
||||
t.Fatalf("filterRule = %#v, want %#v", gotRule, tt.wantRule)
|
||||
}
|
||||
if gotReq.Ext["ruleType"] != ruleType {
|
||||
t.Fatalf("ext.ruleType = %#v, want %q", gotReq.Ext["ruleType"], ruleType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateSubscriptionObjectResponses(t *testing.T) {
|
||||
cases := []map[string]any{
|
||||
{"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1},
|
||||
@@ -151,7 +218,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
|
||||
EventKey: EventSingleChat,
|
||||
RuleType: "singleChat",
|
||||
RuleParam: map[string]any{
|
||||
"targetUid": "507971",
|
||||
"targetUid": "test-user-001",
|
||||
"targetUidType": "staffId",
|
||||
},
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -167,7 +234,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
|
||||
EventSingleChat,
|
||||
"filterRule",
|
||||
"targetUid",
|
||||
"507971",
|
||||
"test-user-001",
|
||||
"sub-1",
|
||||
"req-ok",
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// 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 personal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// MessageEventOutput is the stable business-facing output for personal
|
||||
// message receive events. Schema output is generated from these tags so the
|
||||
// documented fields cannot drift from the values written by consume.
|
||||
type MessageEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
MessageID string `json:"message_id" description:"开放消息 ID" format:"open_message_id"`
|
||||
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
|
||||
Sender string `json:"sender" description:"发送人展示名"`
|
||||
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"发送人开放 ID" format:"open_dingtalk_id"`
|
||||
Content string `json:"content" description:"消息正文"`
|
||||
CreateTime string `json:"create_time" description:"消息创建时间"`
|
||||
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type ReadEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
MessageID string `json:"message_id" description:"被读取消息的开放消息 ID" format:"open_message_id"`
|
||||
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
|
||||
Reader string `json:"reader" description:"读取消息的用户展示名"`
|
||||
ReaderOpenDingTalkID string `json:"reader_open_dingtalk_id" description:"读取消息的用户开放 ID" format:"open_dingtalk_id"`
|
||||
Sender string `json:"sender" description:"原消息发送人展示名"`
|
||||
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
|
||||
ReadTime string `json:"read_time" description:"消息读取时间"`
|
||||
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type RecallEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
MessageID string `json:"message_id" description:"被撤回消息的开放消息 ID" format:"open_message_id"`
|
||||
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
|
||||
Recaller string `json:"recaller" description:"撤回消息的用户展示名"`
|
||||
RecallerOpenDingTalkID string `json:"recaller_open_dingtalk_id" description:"撤回消息的用户开放 ID" format:"open_dingtalk_id"`
|
||||
Sender string `json:"sender" description:"原消息发送人展示名"`
|
||||
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
|
||||
RecallTime string `json:"recall_time" description:"消息撤回时间"`
|
||||
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type ReactionEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
MessageID string `json:"message_id" description:"收到表情回应的开放消息 ID" format:"open_message_id"`
|
||||
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
|
||||
Operator string `json:"operator" description:"执行表情回应操作的用户展示名"`
|
||||
OperatorOpenDingTalkID string `json:"operator_open_dingtalk_id" description:"执行表情回应操作的用户开放 ID" format:"open_dingtalk_id"`
|
||||
ReactionName string `json:"reaction_name" description:"表情回应名称"`
|
||||
ReactionText string `json:"reaction_text" description:"表情回应文本"`
|
||||
OperationType string `json:"operation_type" description:"表情回应操作类型"`
|
||||
OperationTime string `json:"operation_time" description:"表情回应操作时间"`
|
||||
Sender string `json:"sender" description:"原消息发送人展示名"`
|
||||
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
|
||||
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type baseEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
}
|
||||
|
||||
type personalEventData struct {
|
||||
EventID string `json:"eventId"`
|
||||
EventKey string `json:"eventKey"`
|
||||
OccurredAtMS int64 `json:"occurredAtMs"`
|
||||
SubID string `json:"subId"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type personalMessagePayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body struct {
|
||||
CreateTime string `json:"createTime"`
|
||||
Sender string `json:"sender"`
|
||||
OpenMessageID string `json:"openMessageId"`
|
||||
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
|
||||
OpenConversationID string `json:"openConversationId"`
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type personalReadPayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body struct {
|
||||
MessageID string `json:"openMessageId"`
|
||||
ConversationID string `json:"openConversationId"`
|
||||
Reader string `json:"reader"`
|
||||
ReaderOpenDingTalkID string `json:"readerOpenDingTalkId"`
|
||||
Sender string `json:"sender"`
|
||||
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
|
||||
ReadTime string `json:"msgReadTime"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type personalRecallPayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body struct {
|
||||
MessageID string `json:"openMessageId"`
|
||||
ConversationID string `json:"openConversationId"`
|
||||
Recaller string `json:"recaller"`
|
||||
RecallerOpenDingTalkID string `json:"recallerOpenDingTalkId"`
|
||||
Sender string `json:"sender"`
|
||||
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
|
||||
RecallTime string `json:"msgRecallTime"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type personalReactionPayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body personalReactionBody `json:"body"`
|
||||
}
|
||||
|
||||
type personalReactionBody struct {
|
||||
MessageID string `json:"openSourceMessageId"`
|
||||
ConversationID string `json:"openConversationId"`
|
||||
Operator string `json:"oper"`
|
||||
OperatorOpenDingTalkID string `json:"-"`
|
||||
ReactionName string `json:"emotionName"`
|
||||
ReactionText string `json:"emotionText"`
|
||||
OperationType string `json:"operateType"`
|
||||
OperationTime string `json:"operateTime"`
|
||||
Sender string `json:"sender"`
|
||||
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
|
||||
}
|
||||
|
||||
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
|
||||
// encoding/json otherwise falls back to case-insensitive field matching.
|
||||
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
|
||||
type bodyAlias personalReactionBody
|
||||
var decoded bodyAlias
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw, ok := fields["operOpenDingtalkId"]; ok {
|
||||
if err := json.Unmarshal(raw, &decoded.OperatorOpenDingTalkID); err != nil {
|
||||
return fmt.Errorf("decode operOpenDingtalkId: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
*b = personalReactionBody(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectOutput converts the transport envelope into the stable personal
|
||||
// event output. On malformed Data it returns the original envelope together
|
||||
// with an error; the formatter logs the warning and still emits that envelope.
|
||||
func ProjectOutput(ev transport.Event) (any, error) {
|
||||
data, err := decodePersonalEventData(ev.Data)
|
||||
if err != nil {
|
||||
return ev, fmt.Errorf("decode personal event data: %w", err)
|
||||
}
|
||||
|
||||
eventType := firstNonEmptyOutput(ev.EventType, data.EventKey)
|
||||
eventID := firstNonEmptyOutput(data.EventID, ev.EventID)
|
||||
timestamp := data.OccurredAtMS
|
||||
if timestamp == 0 {
|
||||
timestamp = ev.EventBornTime
|
||||
}
|
||||
subscribeID := firstNonEmptyOutput(ev.SubscribeID, data.SubID)
|
||||
|
||||
if isMessageReceiveEvent(eventType) {
|
||||
var payload personalMessagePayload
|
||||
if err := decodeRequiredPayload(data.Payload, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal message payload: %w", err)
|
||||
}
|
||||
return MessageEventOutput{
|
||||
Type: eventType,
|
||||
EventID: eventID,
|
||||
Timestamp: timestamp,
|
||||
SubscribeID: subscribeID,
|
||||
MessageID: payload.Body.OpenMessageID,
|
||||
ConversationID: payload.Body.OpenConversationID,
|
||||
Sender: payload.Body.Sender,
|
||||
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
|
||||
Content: payload.Body.Content,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
base := baseEventOutput{
|
||||
Type: eventType,
|
||||
EventID: eventID,
|
||||
Timestamp: timestamp,
|
||||
SubscribeID: subscribeID,
|
||||
}
|
||||
switch {
|
||||
case isReadEvent(eventType):
|
||||
return projectReadEvent(ev, base, data.Payload)
|
||||
case isRecallEvent(eventType):
|
||||
return projectRecallEvent(ev, base, data.Payload)
|
||||
case isReactionEvent(eventType):
|
||||
return projectReactionEvent(ev, base, data.Payload)
|
||||
default:
|
||||
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func projectReadEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
|
||||
var payload personalReadPayload
|
||||
if err := decodeRequiredPayload(raw, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal read payload: %w", err)
|
||||
}
|
||||
return ReadEventOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
MessageID: payload.Body.MessageID,
|
||||
ConversationID: payload.Body.ConversationID,
|
||||
Reader: payload.Body.Reader,
|
||||
ReaderOpenDingTalkID: payload.Body.ReaderOpenDingTalkID,
|
||||
Sender: payload.Body.Sender,
|
||||
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
|
||||
ReadTime: payload.Body.ReadTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func projectRecallEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
|
||||
var payload personalRecallPayload
|
||||
if err := decodeRequiredPayload(raw, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal recall payload: %w", err)
|
||||
}
|
||||
return RecallEventOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
MessageID: payload.Body.MessageID,
|
||||
ConversationID: payload.Body.ConversationID,
|
||||
Recaller: payload.Body.Recaller,
|
||||
RecallerOpenDingTalkID: payload.Body.RecallerOpenDingTalkID,
|
||||
Sender: payload.Body.Sender,
|
||||
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
|
||||
RecallTime: payload.Body.RecallTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func projectReactionEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
|
||||
var payload personalReactionPayload
|
||||
if err := decodeRequiredPayload(raw, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal reaction payload: %w", err)
|
||||
}
|
||||
return ReactionEventOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
MessageID: payload.Body.MessageID,
|
||||
ConversationID: payload.Body.ConversationID,
|
||||
Operator: payload.Body.Operator,
|
||||
OperatorOpenDingTalkID: payload.Body.OperatorOpenDingTalkID,
|
||||
ReactionName: payload.Body.ReactionName,
|
||||
ReactionText: payload.Body.ReactionText,
|
||||
OperationType: payload.Body.OperationType,
|
||||
OperationTime: payload.Body.OperationTime,
|
||||
Sender: payload.Body.Sender,
|
||||
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeRequiredPayload(raw json.RawMessage, target any) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return fmt.Errorf("payload is missing")
|
||||
}
|
||||
|
||||
var payloadObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &payloadObject); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payloadObject) == 0 {
|
||||
return fmt.Errorf("payload is empty")
|
||||
}
|
||||
|
||||
body, ok := payloadObject["body"]
|
||||
if !ok {
|
||||
return fmt.Errorf("payload body is missing")
|
||||
}
|
||||
body = bytes.TrimSpace(body)
|
||||
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
|
||||
return fmt.Errorf("payload body is missing")
|
||||
}
|
||||
var bodyObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &bodyObject); err != nil {
|
||||
return fmt.Errorf("decode payload body: %w", err)
|
||||
}
|
||||
if len(bodyObject) == 0 {
|
||||
return fmt.Errorf("payload body is empty")
|
||||
}
|
||||
|
||||
return json.Unmarshal(trimmed, target)
|
||||
}
|
||||
|
||||
func decodePersonalEventData(raw string) (personalEventData, error) {
|
||||
encoded := []byte(strings.TrimSpace(raw))
|
||||
if len(encoded) == 0 {
|
||||
return personalEventData{}, fmt.Errorf("empty data")
|
||||
}
|
||||
|
||||
// Some gateways wrap the JSON object in one or more JSON strings. Peel
|
||||
// those wrappers without changing the raw transport envelope.
|
||||
for depth := 0; depth < 2; depth++ {
|
||||
var quoted string
|
||||
if err := json.Unmarshal(encoded, "ed); err != nil {
|
||||
break
|
||||
}
|
||||
encoded = []byte(strings.TrimSpace(quoted))
|
||||
}
|
||||
|
||||
var data personalEventData
|
||||
if err := json.Unmarshal(encoded, &data); err != nil {
|
||||
return personalEventData{}, err
|
||||
}
|
||||
if data.EventKey == "" && data.EventID == "" && data.OccurredAtMS == 0 && data.SubID == "" && len(data.Payload) == 0 {
|
||||
return personalEventData{}, fmt.Errorf("data is not a personal event object")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func outputSchema(eventKey string) map[string]any {
|
||||
outputType := outputTypeForEvent(eventKey)
|
||||
properties := make(map[string]any, outputType.NumField())
|
||||
for i := 0; i < outputType.NumField(); i++ {
|
||||
field := outputType.Field(i)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
property := map[string]any{
|
||||
"type": schemaType(field.Type),
|
||||
}
|
||||
if description := field.Tag.Get("description"); description != "" {
|
||||
property["description"] = description
|
||||
}
|
||||
if format := field.Tag.Get("format"); format != "" {
|
||||
property["format"] = format
|
||||
}
|
||||
if field.Tag.Get("additional_properties") == "true" {
|
||||
property["additionalProperties"] = true
|
||||
}
|
||||
if name == "type" {
|
||||
property["enum"] = []string{eventKey}
|
||||
}
|
||||
properties[name] = property
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
}
|
||||
|
||||
func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
switch {
|
||||
case isMessageReceiveEvent(eventKey):
|
||||
return reflect.TypeOf(MessageEventOutput{})
|
||||
case isReadEvent(eventKey):
|
||||
return reflect.TypeOf(ReadEventOutput{})
|
||||
case isRecallEvent(eventKey):
|
||||
return reflect.TypeOf(RecallEventOutput{})
|
||||
case isReactionEvent(eventKey):
|
||||
return reflect.TypeOf(ReactionEventOutput{})
|
||||
default:
|
||||
return reflect.TypeOf(baseEventOutput{})
|
||||
}
|
||||
}
|
||||
|
||||
func isReadEvent(eventKey string) bool {
|
||||
return eventKey == EventReadO2O || eventKey == EventReadGroup
|
||||
}
|
||||
|
||||
func isRecallEvent(eventKey string) bool {
|
||||
return eventKey == EventRecallO2O || eventKey == EventRecallGroup
|
||||
}
|
||||
|
||||
func isReactionEvent(eventKey string) bool {
|
||||
return eventKey == EventReactionO2O || eventKey == EventReactionGroup
|
||||
}
|
||||
|
||||
func schemaType(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return "integer"
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "object"
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "array"
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return "object"
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyOutput(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// 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 personal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func personalMessageData(eventKey string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"eventId":"data-event",
|
||||
"eventKey":%q,
|
||||
"occurredAtMs":1783483236995,
|
||||
"subId":"data-sub",
|
||||
"payload":{
|
||||
"body":{
|
||||
"createTime":"2026-07-08 12:00:35",
|
||||
"sender":"测试用户甲",
|
||||
"openMessageId":"msg-1",
|
||||
"senderOpenDingTalkId":"open-user-1",
|
||||
"openConversationId":"cid-1",
|
||||
"content":"在吗"
|
||||
},
|
||||
"event_time":1783483235983
|
||||
}
|
||||
}`, eventKey)
|
||||
}
|
||||
|
||||
func personalReadData(eventKey, messageID, conversationID string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"eventId":"read-event",
|
||||
"eventKey":%q,
|
||||
"occurredAtMs":1784008412182,
|
||||
"subId":"read-sub",
|
||||
"payload":{
|
||||
"bizid":"internal-bizid",
|
||||
"body":{
|
||||
"msgReadTime":"2026-07-14 13:53:31",
|
||||
"openConversationId":%q,
|
||||
"openMessageId":%q,
|
||||
"reader":"测试用户乙",
|
||||
"readerOpenDingTalkId":"reader-open-id",
|
||||
"sender":"测试用户甲",
|
||||
"senderOpenDingTalkId":"sender-open-id"
|
||||
},
|
||||
"clientId":"internal-client",
|
||||
"corpid":"internal-corp",
|
||||
"event_time":1784008411652,
|
||||
"filterSubId":"internal-filter",
|
||||
"uid":100001
|
||||
}
|
||||
}`, eventKey, conversationID, messageID)
|
||||
}
|
||||
|
||||
func personalRecallData(eventKey, messageID, conversationID string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"eventId":"recall-event",
|
||||
"eventKey":%q,
|
||||
"occurredAtMs":1784008592969,
|
||||
"subId":"recall-sub",
|
||||
"payload":{
|
||||
"bizid":"internal-bizid",
|
||||
"body":{
|
||||
"msgRecallTime":"2026-07-14 13:56:32",
|
||||
"openConversationId":%q,
|
||||
"openMessageId":%q,
|
||||
"recaller":"测试用户乙",
|
||||
"recallerOpenDingTalkId":"recaller-open-id",
|
||||
"sender":"测试用户乙",
|
||||
"senderOpenDingTalkId":"sender-open-id"
|
||||
},
|
||||
"clientId":"internal-client",
|
||||
"corpid":"internal-corp",
|
||||
"event_time":1784008592766,
|
||||
"filterSubId":"internal-filter",
|
||||
"uid":100001
|
||||
}
|
||||
}`, eventKey, conversationID, messageID)
|
||||
}
|
||||
|
||||
func personalReactionData(eventKey, messageID, conversationID string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"eventId":"reaction-event",
|
||||
"eventKey":%q,
|
||||
"occurredAtMs":1784008680072,
|
||||
"subId":"reaction-sub",
|
||||
"payload":{
|
||||
"bizid":"internal-bizid",
|
||||
"body":{
|
||||
"emotionName":"微笑",
|
||||
"emotionText":"微笑",
|
||||
"openConversationId":%q,
|
||||
"openSourceMessageId":%q,
|
||||
"oper":"测试用户乙",
|
||||
"operOpenDingtalkId":"operator-open-id",
|
||||
"operateTime":"2026-07-14 13:57:59",
|
||||
"operateType":"add",
|
||||
"sender":"测试用户甲",
|
||||
"senderOpenDingTalkId":"sender-open-id"
|
||||
},
|
||||
"clientId":"internal-client",
|
||||
"corpid":"internal-corp",
|
||||
"event_time":1784008679217,
|
||||
"filterSubId":"internal-filter",
|
||||
"uid":100001
|
||||
}
|
||||
}`, eventKey, conversationID, messageID)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
Type: transport.FrameTypeEvent,
|
||||
EventID: "outer-event",
|
||||
EventBornTime: 11,
|
||||
EventType: eventKey,
|
||||
SubscribeID: "outer-sub",
|
||||
Data: personalMessageData(eventKey),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got, ok := projected.(MessageEventOutput)
|
||||
if !ok {
|
||||
t.Fatalf("ProjectOutput() type = %T", projected)
|
||||
}
|
||||
want := MessageEventOutput{
|
||||
Type: eventKey,
|
||||
EventID: "data-event",
|
||||
Timestamp: 1783483236995,
|
||||
SubscribeID: "outer-sub",
|
||||
MessageID: "msg-1",
|
||||
ConversationID: "cid-1",
|
||||
Sender: "测试用户甲",
|
||||
SenderOpenDingTalkID: "open-user-1",
|
||||
Content: "在吗",
|
||||
CreateTime: "2026-07-08 12:00:35",
|
||||
EventTime: 1783483235983,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
|
||||
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projected, err := ProjectOutput(transport.Event{Data: string(wrapped)})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got := projected.(MessageEventOutput)
|
||||
if got.Content != "在吗" || got.Type != EventSingleChat || got.SubscribeID != "data-sub" || got.EventID != "data-event" {
|
||||
t.Fatalf("ProjectOutput() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputFallsBackToTransportFields(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventBornTime: 123,
|
||||
EventType: EventSingleChat,
|
||||
SubscribeID: "outer-sub",
|
||||
Data: `{"payload":{"body":{"content":"hello"}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got := projected.(MessageEventOutput)
|
||||
if got.EventID != "outer-event" || got.Timestamp != 123 || got.SubscribeID != "outer-sub" || got.Type != EventSingleChat {
|
||||
t.Fatalf("fallback fields = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputReadEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventReadO2O, EventReadGroup} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventType: eventKey,
|
||||
Data: personalReadData(eventKey, "read-message", "read-conversation"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
want := ReadEventOutput{
|
||||
Type: eventKey,
|
||||
EventID: "read-event",
|
||||
Timestamp: 1784008412182,
|
||||
SubscribeID: "read-sub",
|
||||
MessageID: "read-message",
|
||||
ConversationID: "read-conversation",
|
||||
Reader: "测试用户乙",
|
||||
ReaderOpenDingTalkID: "reader-open-id",
|
||||
Sender: "测试用户甲",
|
||||
SenderOpenDingTalkID: "sender-open-id",
|
||||
ReadTime: "2026-07-14 13:53:31",
|
||||
EventTime: 1784008411652,
|
||||
}
|
||||
if !reflect.DeepEqual(projected, want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
|
||||
}
|
||||
assertNoInternalActionFields(t, projected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRecallEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventRecallO2O, EventRecallGroup} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventType: eventKey,
|
||||
Data: personalRecallData(eventKey, "recall-message", "recall-conversation"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
want := RecallEventOutput{
|
||||
Type: eventKey,
|
||||
EventID: "recall-event",
|
||||
Timestamp: 1784008592969,
|
||||
SubscribeID: "recall-sub",
|
||||
MessageID: "recall-message",
|
||||
ConversationID: "recall-conversation",
|
||||
Recaller: "测试用户乙",
|
||||
RecallerOpenDingTalkID: "recaller-open-id",
|
||||
Sender: "测试用户乙",
|
||||
SenderOpenDingTalkID: "sender-open-id",
|
||||
RecallTime: "2026-07-14 13:56:32",
|
||||
EventTime: 1784008592766,
|
||||
}
|
||||
if !reflect.DeepEqual(projected, want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
|
||||
}
|
||||
assertNoInternalActionFields(t, projected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputReactionEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventReactionO2O, EventReactionGroup} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventType: eventKey,
|
||||
Data: personalReactionData(eventKey, "reaction-message", "reaction-conversation"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
want := ReactionEventOutput{
|
||||
Type: eventKey,
|
||||
EventID: "reaction-event",
|
||||
Timestamp: 1784008680072,
|
||||
SubscribeID: "reaction-sub",
|
||||
MessageID: "reaction-message",
|
||||
ConversationID: "reaction-conversation",
|
||||
Operator: "测试用户乙",
|
||||
OperatorOpenDingTalkID: "operator-open-id",
|
||||
ReactionName: "微笑",
|
||||
ReactionText: "微笑",
|
||||
OperationType: "add",
|
||||
OperationTime: "2026-07-14 13:57:59",
|
||||
Sender: "测试用户甲",
|
||||
SenderOpenDingTalkID: "sender-open-id",
|
||||
EventTime: 1784008679217,
|
||||
}
|
||||
if !reflect.DeepEqual(projected, want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
|
||||
}
|
||||
assertNoInternalActionFields(t, projected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputReactionRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
|
||||
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
|
||||
t.Run(legacyField, func(t *testing.T) {
|
||||
data := strings.Replace(
|
||||
personalReactionData(EventReactionO2O, "reaction-message", "reaction-conversation"),
|
||||
"operOpenDingtalkId",
|
||||
legacyField,
|
||||
1,
|
||||
)
|
||||
projected, err := ProjectOutput(transport.Event{EventType: EventReactionO2O, Data: data})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got := projected.(ReactionEventOutput)
|
||||
if got.OperatorOpenDingTalkID != "" {
|
||||
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoInternalActionFields(t *testing.T, projected any) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(projected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
|
||||
if strings.Contains(string(raw), `"`+field+`"`) {
|
||||
t.Fatalf("projected output leaked internal field %q: %s", field, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsEmptyPayloads(t *testing.T) {
|
||||
eventKeys := []string{
|
||||
EventMention,
|
||||
EventSingleChat,
|
||||
EventInChat,
|
||||
EventFromUser,
|
||||
EventReadO2O,
|
||||
EventReadGroup,
|
||||
EventRecallO2O,
|
||||
EventRecallGroup,
|
||||
EventReactionO2O,
|
||||
EventReactionGroup,
|
||||
}
|
||||
payloads := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{name: "missing", json: ""},
|
||||
{name: "null", json: `,"payload":null`},
|
||||
{name: "empty object", json: `,"payload":{}`},
|
||||
{name: "missing body", json: `,"payload":{"event_time":1}`},
|
||||
{name: "null body", json: `,"payload":{"body":null}`},
|
||||
{name: "empty body", json: `,"payload":{"body":{}}`},
|
||||
}
|
||||
|
||||
for _, eventKey := range eventKeys {
|
||||
for _, payload := range payloads {
|
||||
t.Run(eventKey+"/"+payload.name, func(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventType: eventKey,
|
||||
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, payload.json),
|
||||
}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil {
|
||||
t.Fatal("ProjectOutput() error = nil, want payload validation error")
|
||||
}
|
||||
got, ok := projected.(transport.Event)
|
||||
if !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputMalformedDataReturnsRawEnvelope(t *testing.T) {
|
||||
ev := transport.Event{EventID: "outer-event", Data: "not-json"}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil {
|
||||
t.Fatal("ProjectOutput() error = nil, want decode error")
|
||||
}
|
||||
got, ok := projected.(transport.Event)
|
||||
if !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v", projected)
|
||||
}
|
||||
}
|
||||
+204
-112
@@ -23,10 +23,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
EventMention = "user_im_message_receive_at"
|
||||
EventSingleChat = "user_im_message_receive_o2o"
|
||||
EventInChat = "user_im_message_receive_group"
|
||||
EventFromUser = "user_im_message_receive_user"
|
||||
EventMention = "user_im_message_receive_at"
|
||||
EventSingleChat = "user_im_message_receive_o2o"
|
||||
EventInChat = "user_im_message_receive_group"
|
||||
EventFromUser = "user_im_message_receive_user"
|
||||
EventReadO2O = "user_im_message_read_o2o"
|
||||
EventReadGroup = "user_im_message_read_group"
|
||||
EventRecallO2O = "user_im_message_recall_o2o"
|
||||
EventRecallGroup = "user_im_message_recall_group"
|
||||
EventReactionO2O = "user_im_message_reaction_o2o"
|
||||
EventReactionGroup = "user_im_message_reaction_group"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,32 +41,40 @@ const (
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
EventKey string `json:"event_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
RuleType string `json:"rule_type"`
|
||||
Status string `json:"status"`
|
||||
RequiredParams []string `json:"required_params"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Public bool `json:"-"`
|
||||
EventKey string `json:"event_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
RuleType string `json:"rule_type"`
|
||||
Status string `json:"status"`
|
||||
RequiredParams []string `json:"required_params"`
|
||||
Constraints *ParameterConstraints `json:"constraints,omitempty"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Public bool `json:"-"`
|
||||
}
|
||||
|
||||
type ParameterConstraints struct {
|
||||
RequireOneOf [][]string `json:"require_one_of,omitempty"`
|
||||
MutuallyExclusive [][]string `json:"mutually_exclusive,omitempty"`
|
||||
}
|
||||
|
||||
type SchemaDocument struct {
|
||||
EventKey string `json:"event_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
RuleType string `json:"rule_type"`
|
||||
RequiredParams []string `json:"required_params"`
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
Schema map[string]any `json:"schema"`
|
||||
EventKey string `json:"event_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
RuleType string `json:"rule_type"`
|
||||
RequiredParams []string `json:"required_params"`
|
||||
Constraints *ParameterConstraints `json:"constraints,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
Schema map[string]any `json:"schema"`
|
||||
}
|
||||
|
||||
type RuleOptions struct {
|
||||
RuleType string
|
||||
UserID string
|
||||
GroupID string
|
||||
RuleType string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
GroupID string
|
||||
}
|
||||
|
||||
type SchemaPendingError struct {
|
||||
@@ -90,7 +104,8 @@ var definitions = []Definition{
|
||||
Category: "im",
|
||||
RuleType: "singleChat",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: []string{"user"},
|
||||
RequiredParams: nil,
|
||||
Constraints: targetUIDConstraints(),
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
@@ -108,25 +123,105 @@ var definitions = []Definition{
|
||||
{
|
||||
EventKey: EventFromUser,
|
||||
DisplayName: "指定发送人消息",
|
||||
Description: "当前用户收到的特别关注用户的消息",
|
||||
Description: "当前用户收到的指定用户发送的消息",
|
||||
Category: "im",
|
||||
RuleType: "sender",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: []string{"user"},
|
||||
RequiredParams: nil,
|
||||
Constraints: targetUIDConstraints(),
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: false,
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventReadO2O,
|
||||
DisplayName: "指定单聊消息已读",
|
||||
Description: "当前用户在指定单聊中发送的消息被对方已读",
|
||||
Category: "im",
|
||||
RuleType: "singleChat",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Constraints: targetUIDConstraints(),
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventReadGroup,
|
||||
DisplayName: "指定群消息已读",
|
||||
Description: "当前用户在指定群聊中发送的消息被已读",
|
||||
Category: "im",
|
||||
RuleType: "group",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: []string{"group"},
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventRecallO2O,
|
||||
DisplayName: "指定单聊消息撤回",
|
||||
Description: "指定单聊中的消息被撤回",
|
||||
Category: "im",
|
||||
RuleType: "singleChat",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Constraints: targetUIDConstraints(),
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventRecallGroup,
|
||||
DisplayName: "指定群消息撤回",
|
||||
Description: "指定群聊中的消息被撤回",
|
||||
Category: "im",
|
||||
RuleType: "group",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: []string{"group"},
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventReactionO2O,
|
||||
DisplayName: "指定单聊消息表情回应",
|
||||
Description: "指定单聊中的消息收到表情回应(贴表情)",
|
||||
Category: "im",
|
||||
RuleType: "singleChat",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Constraints: targetUIDConstraints(),
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventReactionGroup,
|
||||
DisplayName: "指定群消息表情回应",
|
||||
Description: "指定群聊中的消息收到表情回应(贴表情)",
|
||||
Category: "im",
|
||||
RuleType: "group",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: []string{"group"},
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
}
|
||||
|
||||
func targetUIDConstraints() *ParameterConstraints {
|
||||
return &ParameterConstraints{
|
||||
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
|
||||
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
|
||||
}
|
||||
}
|
||||
|
||||
func Definitions() []Definition {
|
||||
out := append([]Definition(nil), definitions...)
|
||||
out := make([]Definition, 0, len(definitions))
|
||||
for _, def := range definitions {
|
||||
out = append(out, cloneDefinition(def))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Lookup(eventKey string) (Definition, bool) {
|
||||
for _, def := range definitions {
|
||||
if def.EventKey == eventKey {
|
||||
return def, true
|
||||
return cloneDefinition(def), true
|
||||
}
|
||||
}
|
||||
return Definition{}, false
|
||||
@@ -157,7 +252,7 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
|
||||
if !includePending && def.Status == StatusPending {
|
||||
continue
|
||||
}
|
||||
out = append(out, def)
|
||||
out = append(out, cloneDefinition(def))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -172,11 +267,46 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
|
||||
Category: def.Category,
|
||||
RuleType: def.RuleType,
|
||||
RequiredParams: requiredParams,
|
||||
JQRootPath: ".data | fromjson",
|
||||
Schema: personalMessageSchema(def.EventKey),
|
||||
Constraints: cloneParameterConstraints(def.Constraints),
|
||||
JQRootPath: ".",
|
||||
Schema: outputSchema(def.EventKey),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDefinition(def Definition) Definition {
|
||||
def.RequiredParams = append([]string(nil), def.RequiredParams...)
|
||||
def.Constraints = cloneParameterConstraints(def.Constraints)
|
||||
if def.Auth != nil {
|
||||
def.Auth = cloneMap(def.Auth)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func cloneParameterConstraints(in *ParameterConstraints) *ParameterConstraints {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
cloneGroups := func(groups [][]string) [][]string {
|
||||
out := make([][]string, len(groups))
|
||||
for i, group := range groups {
|
||||
out[i] = append([]string(nil), group...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return &ParameterConstraints{
|
||||
RequireOneOf: cloneGroups(in.RequireOneOf),
|
||||
MutuallyExclusive: cloneGroups(in.MutuallyExclusive),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, ruleParam map[string]any, err error) {
|
||||
def, ok := Lookup(eventKey)
|
||||
if !ok {
|
||||
@@ -189,44 +319,33 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
|
||||
return "", nil, &SchemaPendingError{EventKey: eventKey}
|
||||
}
|
||||
userID := strings.TrimSpace(opts.UserID)
|
||||
openDingTalkID := strings.TrimSpace(opts.OpenDingTalkID)
|
||||
groupID := strings.TrimSpace(opts.GroupID)
|
||||
switch def.RuleType {
|
||||
case "at":
|
||||
if userID != "" {
|
||||
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
|
||||
return "", nil, fmt.Errorf("--user is not supported for %s", eventKey)
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s", eventKey)
|
||||
}
|
||||
if groupID != "" {
|
||||
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
|
||||
return "", nil, fmt.Errorf("--group is not supported for %s", eventKey)
|
||||
}
|
||||
return def.RuleType, map[string]any{}, nil
|
||||
case "singleChat":
|
||||
if groupID != "" {
|
||||
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
|
||||
}
|
||||
if userID == "" {
|
||||
return "", nil, fmt.Errorf("--user is required")
|
||||
}
|
||||
return def.RuleType, map[string]any{
|
||||
"targetUid": userID,
|
||||
"targetUidType": "staffId",
|
||||
}, nil
|
||||
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
|
||||
case "sender":
|
||||
if groupID != "" {
|
||||
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
|
||||
}
|
||||
if userID == "" {
|
||||
return "", nil, fmt.Errorf("--user is required")
|
||||
}
|
||||
return def.RuleType, map[string]any{
|
||||
"targetUid": userID,
|
||||
"targetUidType": "staffId",
|
||||
}, nil
|
||||
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
|
||||
case "group":
|
||||
if userID != "" {
|
||||
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
|
||||
return "", nil, fmt.Errorf("--user is not supported for %s; use --group", eventKey)
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s; use --group", eventKey)
|
||||
}
|
||||
if groupID == "" {
|
||||
return "", nil, fmt.Errorf("--group is required")
|
||||
return "", nil, fmt.Errorf("--group is required for %s", eventKey)
|
||||
}
|
||||
return def.RuleType, map[string]any{
|
||||
"openConversationId": groupID,
|
||||
@@ -236,6 +355,28 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
|
||||
}
|
||||
}
|
||||
|
||||
func buildTargetUIDRuleParam(ruleType, eventKey, userID, openDingTalkID, groupID string) (string, map[string]any, error) {
|
||||
if groupID != "" {
|
||||
return "", nil, fmt.Errorf("--group is not supported for %s; use --user or --open-dingtalk-id", eventKey)
|
||||
}
|
||||
if userID != "" && openDingTalkID != "" {
|
||||
return "", nil, fmt.Errorf("--user and --open-dingtalk-id are mutually exclusive for %s", eventKey)
|
||||
}
|
||||
if userID == "" && openDingTalkID == "" {
|
||||
return "", nil, fmt.Errorf("one of --user or --open-dingtalk-id is required for %s", eventKey)
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
return ruleType, map[string]any{
|
||||
"targetUid": openDingTalkID,
|
||||
"targetUidType": "openDingtalkId",
|
||||
}, nil
|
||||
}
|
||||
return ruleType, map[string]any{
|
||||
"targetUid": userID,
|
||||
"targetUidType": "staffId",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildFilter(filterJSON string, queryCSV string) (any, string, error) {
|
||||
var parts []any
|
||||
filterJSON = strings.TrimSpace(filterJSON)
|
||||
@@ -337,61 +478,12 @@ func normalizeFilterAliases(v any) any {
|
||||
}
|
||||
}
|
||||
|
||||
func personalMessageSchema(eventKey string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "事件类型,固定为当前 event_key",
|
||||
"enum": []string{eventKey},
|
||||
},
|
||||
"event_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "事件 ID,可用于去重",
|
||||
},
|
||||
"timestamp": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "事件发生时间戳,对应 occurredAtMs",
|
||||
"format": "timestamp_ms",
|
||||
},
|
||||
"subscribe_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "订阅 ID,对应 subId",
|
||||
},
|
||||
"message_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "开放消息 ID,对应 payload.body.openMessageId",
|
||||
"format": "open_message_id",
|
||||
},
|
||||
"conversation_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "会话 ID,对应 payload.body.openConversationId",
|
||||
"format": "open_conversation_id",
|
||||
},
|
||||
"sender": map[string]any{
|
||||
"type": "string",
|
||||
"description": "发送人展示名,对应 payload.body.sender",
|
||||
},
|
||||
"sender_open_dingtalk_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "发送人开放 ID,对应 payload.body.senderOpenDingTalkId",
|
||||
"format": "open_dingtalk_id",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "消息正文,对应 payload.body.content",
|
||||
},
|
||||
"create_time": map[string]any{
|
||||
"type": "string",
|
||||
"description": "消息创建时间,对应 payload.body.createTime",
|
||||
},
|
||||
"event_time": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "消息事件时间戳,对应 payload.event_time",
|
||||
"format": "timestamp_ms",
|
||||
},
|
||||
},
|
||||
func isMessageReceiveEvent(eventKey string) bool {
|
||||
switch eventKey {
|
||||
case EventMention, EventSingleChat, EventInChat, EventFromUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,18 +33,25 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventMention,
|
||||
EventSingleChat,
|
||||
EventInChat,
|
||||
EventFromUser,
|
||||
EventReadO2O,
|
||||
EventReadGroup,
|
||||
EventRecallO2O,
|
||||
EventRecallGroup,
|
||||
EventReactionO2O,
|
||||
EventReactionGroup,
|
||||
}
|
||||
if !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("keys = %#v, want %#v", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromUserRemainsInternalButNotPublic(t *testing.T) {
|
||||
func TestEventFromUserIsPublic(t *testing.T) {
|
||||
if _, ok := Lookup(EventFromUser); !ok {
|
||||
t.Fatalf("Lookup(%q) failed, want internal definition retained", EventFromUser)
|
||||
t.Fatalf("Lookup(%q) failed", EventFromUser)
|
||||
}
|
||||
if IsPublic(EventFromUser) {
|
||||
t.Fatalf("IsPublic(%q) = true, want hidden", EventFromUser)
|
||||
if !IsPublic(EventFromUser) {
|
||||
t.Fatalf("IsPublic(%q) = false, want public", EventFromUser)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +61,16 @@ func TestLegacyEventKeysAreUnknown(t *testing.T) {
|
||||
"im_message_receive_o2o",
|
||||
"im_message_receive_group",
|
||||
"im_message_receive_user",
|
||||
"im_message_read_o2o",
|
||||
"im_message_read_group",
|
||||
"im_message_recall_o2o",
|
||||
"im_message_recall_group",
|
||||
"im_message_emotion_o2o",
|
||||
"im_message_emotion_group",
|
||||
"im_message_reaction_o2o",
|
||||
"im_message_reaction_group",
|
||||
"user_im_message_emotion_o2o",
|
||||
"user_im_message_emotion_group",
|
||||
}
|
||||
for _, key := range legacyKeys {
|
||||
if _, ok := Lookup(key); ok {
|
||||
@@ -140,8 +157,8 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
|
||||
}
|
||||
}
|
||||
if doc.JQRootPath != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
if doc.RequiredParams == nil {
|
||||
t.Fatalf("required_params = nil, want empty slice")
|
||||
@@ -150,13 +167,151 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
|
||||
}
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
|
||||
"create_time", "event_time",
|
||||
}
|
||||
if len(props) != len(wantProperties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d DTO fields", props, len(wantProperties))
|
||||
}
|
||||
for _, name := range wantProperties {
|
||||
if _, ok := props[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetUIDEventSchemasRequireUserOrOpenDingTalkID(t *testing.T) {
|
||||
wantConstraints := &ParameterConstraints{
|
||||
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
|
||||
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
|
||||
}
|
||||
for _, eventKey := range []string{
|
||||
EventSingleChat,
|
||||
EventFromUser,
|
||||
EventReadO2O,
|
||||
EventRecallO2O,
|
||||
EventReactionO2O,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", eventKey)
|
||||
}
|
||||
if len(def.RequiredParams) != 0 {
|
||||
t.Fatalf("required_params = %#v, want no unconditional parameters", def.RequiredParams)
|
||||
}
|
||||
if !reflect.DeepEqual(def.Constraints, wantConstraints) {
|
||||
t.Fatalf("constraints = %#v, want %#v", def.Constraints, wantConstraints)
|
||||
}
|
||||
doc := BuildSchemaDocument(def)
|
||||
if !reflect.DeepEqual(doc.Constraints, wantConstraints) {
|
||||
t.Fatalf("schema constraints = %#v, want %#v", doc.Constraints, wantConstraints)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mention, _ := Lookup(EventMention)
|
||||
if len(mention.RequiredParams) != 0 {
|
||||
t.Fatalf("mention required_params = %#v, want none", mention.RequiredParams)
|
||||
}
|
||||
if mention.Constraints != nil {
|
||||
t.Fatalf("mention constraints = %#v, want none", mention.Constraints)
|
||||
}
|
||||
group, _ := Lookup(EventInChat)
|
||||
if want := []string{"group"}; !reflect.DeepEqual(group.RequiredParams, want) {
|
||||
t.Fatalf("group required_params = %#v, want %#v", group.RequiredParams, want)
|
||||
}
|
||||
if group.Constraints != nil {
|
||||
t.Fatalf("group constraints = %#v, want none", group.Constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionCopiesDoNotMutateRegistryConstraints(t *testing.T) {
|
||||
def, ok := Lookup(EventSingleChat)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", EventSingleChat)
|
||||
}
|
||||
def.Constraints.RequireOneOf[0][0] = "mutated"
|
||||
|
||||
again, _ := Lookup(EventSingleChat)
|
||||
if got := again.Constraints.RequireOneOf[0][0]; got != "user" {
|
||||
t.Fatalf("registry constraint mutated through lookup copy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventKeys []string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
name: "read",
|
||||
eventKeys: []string{EventReadO2O, EventReadGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
|
||||
"sender_open_dingtalk_id", "read_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
eventKeys: []string{EventRecallO2O, EventRecallGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
|
||||
"sender_open_dingtalk_id", "recall_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reaction",
|
||||
eventKeys: []string{EventReactionO2O, EventReactionGroup},
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
|
||||
"reaction_text", "operation_type", "operation_time", "sender",
|
||||
"sender_open_dingtalk_id", "event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, eventKey := range tt.eventKeys {
|
||||
t.Run(tt.name+"/"+eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", eventKey)
|
||||
}
|
||||
doc := BuildSchemaDocument(def)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
props, ok := doc.Schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v", doc.Schema["properties"])
|
||||
}
|
||||
if len(props) != len(tt.properties) {
|
||||
t.Fatalf("schema properties = %#v, want exactly %d DTO fields", props, len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := props[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
|
||||
if _, ok := props[internal]; ok {
|
||||
t.Fatalf("schema exposed internal property %q", internal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamMention(t *testing.T) {
|
||||
rule, param, err := BuildRuleParam(EventMention, RuleOptions{})
|
||||
if err != nil {
|
||||
@@ -172,8 +327,8 @@ func TestBuildRuleParamMention(t *testing.T) {
|
||||
|
||||
func TestBuildRuleParamSingleChatRequiresPeer(t *testing.T) {
|
||||
_, _, err := BuildRuleParam(EventSingleChat, RuleOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "--user is required") {
|
||||
t.Fatalf("error = %v, want user requirement", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
|
||||
t.Fatalf("error = %v, want target identity requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,9 +345,22 @@ func TestBuildRuleParamSingleChatUserIDMapsToStaffID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamSingleChatOpenDingTalkID(t *testing.T) {
|
||||
rule, param, err := BuildRuleParam(EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRuleParam() error = %v", err)
|
||||
}
|
||||
if rule != "singleChat" {
|
||||
t.Fatalf("rule = %q, want singleChat", rule)
|
||||
}
|
||||
if param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
|
||||
t.Fatalf("param = %#v", param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamSender(t *testing.T) {
|
||||
_, _, err := BuildRuleParam(EventFromUser, RuleOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "--user is required") {
|
||||
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
|
||||
t.Fatalf("error = %v, want sender requirement", err)
|
||||
}
|
||||
|
||||
@@ -206,6 +374,14 @@ func TestBuildRuleParamSender(t *testing.T) {
|
||||
if param["targetUidType"] != "staffId" || param["targetUid"] != "staff-1" {
|
||||
t.Fatalf("param = %#v", param)
|
||||
}
|
||||
|
||||
rule, param, err = BuildRuleParam(EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRuleParam(openDingtalkId) error = %v", err)
|
||||
}
|
||||
if rule != "sender" || param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
|
||||
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamGroup(t *testing.T) {
|
||||
@@ -226,16 +402,73 @@ func TestBuildRuleParamGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamActionEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventReadO2O, EventRecallO2O, EventReactionO2O} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+eventKey) {
|
||||
t.Fatalf("missing target identity error = %v", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+eventKey) {
|
||||
t.Fatalf("wrong group error = %v", err)
|
||||
}
|
||||
rule, param, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule != "singleChat" || param["targetUid"] != "staff-1" || param["targetUidType"] != "staffId" {
|
||||
t.Fatalf("rule = %q, param = %#v", rule, param)
|
||||
}
|
||||
rule, param, err = BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule != "singleChat" || param["targetUid"] != "open-user-1" || param["targetUidType"] != "openDingtalkId" {
|
||||
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventReactionGroup} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
|
||||
t.Fatalf("missing group error = %v", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
|
||||
t.Fatalf("wrong user error = %v", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+eventKey) {
|
||||
t.Fatalf("wrong openDingtalkId error = %v", err)
|
||||
}
|
||||
rule, param, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule != "group" || param["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("rule = %q, param = %#v", rule, param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleParamRejectsWrongScopedFlags(t *testing.T) {
|
||||
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
|
||||
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventMention) {
|
||||
t.Fatalf("mention with user error = %v, want unsupported user", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is only supported") {
|
||||
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+EventSingleChat) {
|
||||
t.Fatalf("singleChat with group error = %v, want unsupported group", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
|
||||
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+EventSingleChat) {
|
||||
t.Fatalf("singleChat with both identities error = %v, want mutually exclusive", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(EventMention, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventMention) {
|
||||
t.Fatalf("mention with openDingtalkId error = %v, want unsupported openDingtalkId", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventInChat) {
|
||||
t.Fatalf("group with user error = %v, want unsupported user", err)
|
||||
}
|
||||
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{OpenDingTalkID: "open-user-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventInChat) {
|
||||
t.Fatalf("group with openDingtalkId error = %v, want unsupported openDingtalkId", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilterQueryAndJSON(t *testing.T) {
|
||||
@@ -262,10 +495,25 @@ func TestBuildFilterQueryAndJSON(t *testing.T) {
|
||||
func TestIdempotencyKeyUsesLocalIdentityKey(t *testing.T) {
|
||||
left := Identity{LocalSubject: "refresh:left", ClientID: "client-1", SourceID: "open"}
|
||||
right := Identity{LocalSubject: "refresh:right", ClientID: "client-1", SourceID: "open"}
|
||||
ruleParam := map[string]any{"targetUid": "507971", "targetUidType": "staffId"}
|
||||
ruleParam := map[string]any{"targetUid": "test-user-001", "targetUidType": "staffId"}
|
||||
leftKey := IdempotencyKey(left, EventSingleChat, "singleChat", ruleParam, "")
|
||||
rightKey := IdempotencyKey(right, EventSingleChat, "singleChat", ruleParam, "")
|
||||
if leftKey == rightKey {
|
||||
t.Fatalf("idempotency key collapsed for different local subjects: %s", leftKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyKeySeparatesTargetUIDTypes(t *testing.T) {
|
||||
identity := Identity{LocalSubject: "refresh:subject", ClientID: "client-1", SourceID: "open"}
|
||||
staffIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
|
||||
"targetUid": "same-value",
|
||||
"targetUidType": "staffId",
|
||||
}, "")
|
||||
openIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
|
||||
"targetUid": "same-value",
|
||||
"targetUidType": "openDingtalkId",
|
||||
}, "")
|
||||
if staffIDKey == openIDKey {
|
||||
t.Fatalf("idempotency key collapsed for different targetUidType: %s", staffIDKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +615,82 @@ func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalSourceActionEventPassesNormalBusFilter(t *testing.T) {
|
||||
const (
|
||||
eventKey = "user_im_message_reaction_group"
|
||||
subscribeID = "sub-reaction-group"
|
||||
)
|
||||
src := personalSourceForRawEventTests()
|
||||
raw := src.rawEventFromDataFrame(&payload.DataFrame{
|
||||
Headers: payload.DataFrameHeader{
|
||||
"EVENT_TYPE": eventKey,
|
||||
"SUB_ID": subscribeID,
|
||||
},
|
||||
Data: `{"eventKey":"user_im_message_reaction_group","subId":"sub-reaction-group","payload":{}}`,
|
||||
})
|
||||
h := bus.NewHub(10)
|
||||
consumer, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{eventKey},
|
||||
SubscribeID: subscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.Deliver(raw)
|
||||
|
||||
select {
|
||||
case frame := <-consumer.SendCh:
|
||||
eventFrame, ok := frame.(transport.Event)
|
||||
if !ok {
|
||||
t.Fatalf("frame = %T, want transport.Event", frame)
|
||||
}
|
||||
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
|
||||
t.Fatalf("event = %#v", eventFrame)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for action event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalSourceSenderEventPassesNormalBusFilter(t *testing.T) {
|
||||
const (
|
||||
eventKey = "user_im_message_receive_user"
|
||||
subscribeID = "sub-receive-user"
|
||||
)
|
||||
src := personalSourceForRawEventTests()
|
||||
raw := src.rawEventFromDataFrame(&payload.DataFrame{
|
||||
Headers: payload.DataFrameHeader{
|
||||
"EVENT_TYPE": eventKey,
|
||||
"SUB_ID": subscribeID,
|
||||
},
|
||||
Data: `{"eventKey":"user_im_message_receive_user","subId":"sub-receive-user","payload":{}}`,
|
||||
})
|
||||
h := bus.NewHub(10)
|
||||
consumer, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{eventKey},
|
||||
SubscribeID: subscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.Deliver(raw)
|
||||
|
||||
select {
|
||||
case frame := <-consumer.SendCh:
|
||||
eventFrame, ok := frame.(transport.Event)
|
||||
if !ok {
|
||||
t.Fatalf("frame = %T, want transport.Event", frame)
|
||||
}
|
||||
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
|
||||
t.Fatalf("event = %#v", eventFrame)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for sender event")
|
||||
}
|
||||
}
|
||||
|
||||
func personalSourceForRawEventTests() *PersonalSource {
|
||||
return &PersonalSource{cfg: PersonalConfig{
|
||||
SourceID: "fallback_source",
|
||||
|
||||
@@ -4150,6 +4150,10 @@ role-get 自行 merge)。
|
||||
if err := validateRequiredFlags(cmd, "file-name"); err != nil {
|
||||
return err
|
||||
}
|
||||
fileSize, _ := cmd.Flags().GetInt64("file-size")
|
||||
if fileSize <= 0 {
|
||||
return fmt.Errorf("flag --file-size is required and must be a positive integer")
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -4157,9 +4161,7 @@ role-get 自行 merge)。
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"fileName": mustGetFlag(cmd, "file-name"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("file-size"); v > 0 {
|
||||
toolArgs["fileSize"] = v
|
||||
"fileSize": fileSize,
|
||||
}
|
||||
return callAitableTool("prepare_import_upload", toolArgs)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runAitableImportUploadCommand(t *testing.T, args ...string) (*aitableTestCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := newAitableCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(append([]string{"import", "upload"}, args...))
|
||||
return caller, cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func TestAitableImportUploadRequiresPositiveFileSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing", args: nil},
|
||||
{name: "zero", args: []string{"--file-size", "0"}},
|
||||
{name: "negative", args: []string{"--file-size", "-1"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base-smoke", "--file-name", "data.xlsx"}
|
||||
caller, err := runAitableImportUploadCommand(t, append(args, test.args...)...)
|
||||
if err == nil {
|
||||
t.Fatal("expected --file-size validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--file-size") {
|
||||
t.Fatalf("error = %q, want --file-size validation", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid file size dispatched %d MCP call(s): %#v", len(caller.calls), caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableImportUploadPassesFileSizeToMCP(t *testing.T) {
|
||||
caller, err := runAitableImportUploadCommand(t,
|
||||
"--base-id", "base-smoke",
|
||||
"--file-name", "data.xlsx",
|
||||
"--file-size", "204800",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("aitable import upload returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "prepare_import_upload" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/prepare_import_upload", call.server, call.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"baseId": "base-smoke",
|
||||
"fileName": "data.xlsx",
|
||||
"fileSize": int64(204800),
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -189,10 +188,6 @@ func isNumericUserID(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// dingTalkMediaIDPattern 匹配钉钉媒体 ID(上传接口返回),固定以 @ 开头,
|
||||
// 后跟 base64 风格字符(如 @lADPfake、@lQDPM4DCjE1Mk_nNBInNBImw...)。
|
||||
var dingTalkMediaIDPattern = regexp.MustCompile(`^@[A-Za-z0-9_\-+/=.]{4,}$`)
|
||||
|
||||
func isOpenDingTalkID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
|
||||
@@ -1351,10 +1346,9 @@ func newChatCommand() *cobra.Command {
|
||||
timeVal := mustGetFlag(cmd, "time")
|
||||
if groupID != "" {
|
||||
toolArgs := map[string]any{
|
||||
"openCid": groupID,
|
||||
"cid": groupID,
|
||||
"time": timeVal,
|
||||
"forward": forward,
|
||||
"openconversation_id": groupID,
|
||||
"time": timeVal,
|
||||
"forward": forward,
|
||||
}
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
@@ -3366,10 +3360,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
return err
|
||||
}
|
||||
iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id"))
|
||||
// 客户端预校验:钉钉媒体 ID 由上传接口(如 dt_media_upload)返回,
|
||||
// 固定以 @ 开头(形如 @lADP.../@lQDP...)。明显非法的值本地直接报错。
|
||||
if !dingTalkMediaIDPattern.MatchString(iconMediaID) {
|
||||
return fmt.Errorf("invalid --icon-media-id %q: 钉钉媒体 ID 应以 @ 开头(如 @lADP...)\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId", iconMediaID)
|
||||
if iconMediaID == "" {
|
||||
return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId")
|
||||
}
|
||||
return callMCPToolOnServer("im", "update_group_icon", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
|
||||
@@ -43,6 +43,48 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mediaID string
|
||||
}{
|
||||
{name: "at prefix", mediaID: "@lADPvalidMediaID"},
|
||||
{name: "dollar prefix", mediaID: "$iAEvalidMediaID"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "update-icon", "--group=cid", "--icon-media-id="+tc.mediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("update group icon with uploaded media ID %q: %v", tc.mediaID, err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "update-icon", "--group=cid", "--icon-media-id= ")
|
||||
if err == nil {
|
||||
t.Fatal("update group icon with a blank media ID succeeded, want validation error")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -161,7 +161,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50")
|
||||
@@ -171,7 +171,7 @@ func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T)
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
want := map[string]any{"openCid": "cid-1", "cid": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
|
||||
want := map[string]any{"openconversation_id": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
|
||||
@@ -249,6 +249,14 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]
|
||||
|
||||
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
|
||||
if deps.Caller.DryRun() {
|
||||
if deps.Caller.Format() == "json" {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": toolName,
|
||||
"arguments": args,
|
||||
})
|
||||
}
|
||||
bold := color.New(color.FgYellow, color.Bold)
|
||||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||||
deps.Out.PrintKeyValue("Tool", toolName)
|
||||
|
||||
@@ -3,9 +3,11 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -176,6 +178,30 @@ func TestCrossPlatformCoverageMCPOutputModesAndDevdocFormatting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPDryRunJSONOutputIsSingleDocument(t *testing.T) {
|
||||
caller := &helpersCoreCaller{format: "json", dry: true}
|
||||
out, _ := installHelpersCoreDeps(t, caller)
|
||||
|
||||
args := map[string]any{"name": "weekly", "enabled": true}
|
||||
if err := callMCPToolInternalOpts("server", "create_workflow", args, false); err != nil {
|
||||
t.Fatalf("dry run returned error: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run called tool %d times, want 0", caller.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("dry-run stdout must be one JSON document: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "create_workflow" {
|
||||
t.Fatalf("unexpected dry-run payload: %#v", payload)
|
||||
}
|
||||
if !reflect.DeepEqual(payload["arguments"], map[string]any{"name": "weekly", "enabled": true}) {
|
||||
t.Fatalf("arguments = %#v", payload["arguments"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCurrentUserResponseShapes(t *testing.T) {
|
||||
caller := &helpersCoreCaller{}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
|
||||
@@ -202,8 +202,21 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": cfg.operation,
|
||||
"file": file.path,
|
||||
"name": file.name,
|
||||
"format": file.extension,
|
||||
"size": file.size,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", cfg.operation)
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", file.name)
|
||||
@@ -217,7 +230,9 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[1/4] 创建导入会话...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[1/4] 创建导入会话...")
|
||||
}
|
||||
sessionArgs := map[string]any{
|
||||
"fileName": file.name,
|
||||
"suffix": file.extension,
|
||||
@@ -241,18 +256,28 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
sessionID, _ := sessionResult["sessionId"].(string)
|
||||
uploadURL, _ := sessionResult["uploadUrl"].(string)
|
||||
if sessionID == "" || uploadURL == "" {
|
||||
deps.Out.PrintRaw(sessionText)
|
||||
if !jsonMode {
|
||||
deps.Out.PrintRaw(sessionText)
|
||||
}
|
||||
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[2/4] 上传文件...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[2/4] 上传文件...")
|
||||
}
|
||||
if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil {
|
||||
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
deps.Out.PrintInfo(" 文件上传完成")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(" 文件上传完成")
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
|
||||
}
|
||||
confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
|
||||
@@ -263,12 +288,18 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
}
|
||||
taskID, _ := confirmResult["taskId"].(string)
|
||||
if taskID == "" {
|
||||
deps.Out.PrintRaw(confirmText)
|
||||
if !jsonMode {
|
||||
deps.Out.PrintRaw(confirmText)
|
||||
}
|
||||
return fmt.Errorf("确认导入成功但未返回 taskId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
|
||||
}
|
||||
result, err := pollImportTask(ctx, taskID, cfg)
|
||||
if err != nil {
|
||||
var timeoutErr *importPollTimeoutError
|
||||
@@ -276,15 +307,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
return err
|
||||
}
|
||||
if cfg.timeoutAsResult {
|
||||
deps.Out.PrintInfo(timeoutErr.Error())
|
||||
_ = deps.Out.PrintJSON(map[string]any{
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(timeoutErr.Error())
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": false,
|
||||
"timed_out": true,
|
||||
"taskId": taskID,
|
||||
"status": "processing",
|
||||
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
|
||||
}
|
||||
@@ -302,9 +334,10 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
|
||||
if cfg.includeNodeID {
|
||||
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
|
||||
_ = deps.Out.PrintJSON(finalResult)
|
||||
return nil
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
|
||||
}
|
||||
return deps.Out.PrintJSON(finalResult)
|
||||
}
|
||||
|
||||
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
|
||||
@@ -313,6 +346,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
|
||||
return fmt.Errorf("flag --task-id is required")
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
if deps.Caller.Format() == "json" {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": cfg.queryOperation,
|
||||
"taskId": taskID,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
|
||||
deps.Out.PrintKeyValue("任务ID", taskID)
|
||||
return nil
|
||||
@@ -339,15 +381,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
|
||||
documentURL, _ := result["documentUrl"].(string)
|
||||
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
|
||||
}
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
return deps.Out.PrintJSON(result)
|
||||
}
|
||||
if strings.EqualFold(status, "processing") {
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
return deps.Out.PrintJSON(result)
|
||||
}
|
||||
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
if err := deps.Out.PrintJSON(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if message != "" {
|
||||
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
|
||||
}
|
||||
@@ -361,7 +403,9 @@ func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (m
|
||||
}
|
||||
for attempt := 1; attempt <= poll.maxPolls; attempt++ {
|
||||
interval := poll.interval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
|
||||
if deps.Caller.Format() != "json" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
|
||||
}
|
||||
if err := poll.wait(ctx, interval); err != nil {
|
||||
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ type sheetImportCall struct {
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type forcedJSONWriteFailure struct{}
|
||||
|
||||
func (forcedJSONWriteFailure) Write([]byte) (int, error) {
|
||||
return 0, fmt.Errorf("forced JSON output failure")
|
||||
}
|
||||
|
||||
type sheetImportCaller struct {
|
||||
calls []sheetImportCall
|
||||
responses map[string][]string
|
||||
@@ -130,7 +136,7 @@ func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSheetImportRunsSharedDocImportFlow(t *testing.T) {
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
|
||||
"confirm_import": {`{"taskId":"task-1"}`},
|
||||
@@ -171,12 +177,16 @@ func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
|
||||
t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) {
|
||||
t.Fatalf("output missing success contract:\n%s", output)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
||||
t.Fatalf("sheet import stdout must be one JSON document: %v\n%s", err, output)
|
||||
}
|
||||
if payload["nodeId"] != "node-1" || payload["success"] != true {
|
||||
t.Fatalf("output missing success contract: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
|
||||
filePath := writeImportFixture(t, "xlsx")
|
||||
legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
|
||||
"--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
|
||||
@@ -191,6 +201,13 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
|
||||
if leaf != legacy {
|
||||
t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(legacy), &payload); err != nil {
|
||||
t.Fatalf("sheet import dry-run stdout must be one JSON document: %v\n%s", err, legacy)
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["operation"] != "导入本地表格文件为在线电子表格" {
|
||||
t.Fatalf("unexpected dry-run payload: %#v", payload)
|
||||
}
|
||||
|
||||
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
|
||||
create, _, err := root.Find([]string{"create"})
|
||||
@@ -202,7 +219,7 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
|
||||
cfg := fastSheetImportConfig()
|
||||
cfg.poll.maxPolls = 2
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
@@ -220,15 +237,12 @@ func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("timeout should exit successfully, got %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"success": false`,
|
||||
`"timed_out": true`,
|
||||
`"status": "processing"`,
|
||||
`"next_command": "dws sheet import get --task-id task-1"`,
|
||||
} {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Fatalf("output missing %s:\n%s", fragment, output)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
||||
t.Fatalf("timeout stdout must be one JSON document: %v\n%s", err, output)
|
||||
}
|
||||
if payload["success"] != false || payload["timed_out"] != true || payload["status"] != "processing" || payload["next_command"] != "dws sheet import get --task-id task-1" {
|
||||
t.Fatalf("unexpected timeout payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +262,7 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
jsonStart := strings.Index(output, "{")
|
||||
if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil {
|
||||
if json.Unmarshal([]byte(output), &result) != nil {
|
||||
t.Fatalf("invalid JSON output:\n%s", output)
|
||||
}
|
||||
if result["nodeId"] != "node-2" {
|
||||
@@ -257,6 +270,58 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportGetPropagatesJSONWriteFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
}{
|
||||
{name: "completed", response: `{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/"}`},
|
||||
{name: "processing", response: `{"status":"processing"}`},
|
||||
{name: "failed", response: `{"status":"failed","message":"conversion failed"}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
"query_import_task": {tt.response},
|
||||
}}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = forcedJSONWriteFailure{}
|
||||
|
||||
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"get", "--task-id", "task-write-failure"})
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetImportGetDryRunJSONIsSingleDocument(t *testing.T) {
|
||||
caller := &sheetImportCaller{dryRun: true}
|
||||
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("sheet import get dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run remote calls = %#v, want none", caller.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
||||
t.Fatalf("sheet import get dry-run stdout must be one JSON document: %v\n%s", err, output)
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["taskId"] != "task-dry-run" || payload["operation"] != "查询表格导入任务结果" {
|
||||
t.Fatalf("unexpected sheet import get dry-run payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocImportConfigPreservesExistingContract(t *testing.T) {
|
||||
cfg := docImportFlowConfig()
|
||||
for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} {
|
||||
@@ -269,7 +334,7 @@ func TestDocImportConfigPreservesExistingContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -293,8 +358,12 @@ func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
|
||||
}
|
||||
if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") {
|
||||
t.Fatalf("unexpected dry-run output:\n%s", output.String())
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("doc import dry-run stdout must be one JSON document: %v\n%s", err, output.String())
|
||||
}
|
||||
if payload["operation"] != "导入本地文件为在线文档" || payload["format"] != "md" || payload["dry_run"] != true || payload["preview_kind"] != "plan" {
|
||||
t.Fatalf("unexpected dry-run output: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -462,11 +462,11 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range)
|
||||
if deps.Caller.DryRun() {
|
||||
// The batch command normally uses callMCPToolReturnText in
|
||||
// JSON mode, which intentionally performs a real call. Keep
|
||||
// the dry-run guard before both output branches so neither
|
||||
// format can bypass the shared no-network preview path.
|
||||
_ = callMCPTool("update_range", toolArgs)
|
||||
// JSON mode emits one aggregate preview below. Human formats
|
||||
// reuse the shared per-call preview printer.
|
||||
if !jsonMode {
|
||||
_ = callMCPTool("update_range", toolArgs)
|
||||
}
|
||||
if jsonMode {
|
||||
jsonResults = append(jsonResults, map[string]any{
|
||||
"index": i + 1,
|
||||
@@ -522,12 +522,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed)
|
||||
if jsonMode {
|
||||
_ = deps.Out.PrintJSON(map[string]any{
|
||||
if err := deps.Out.PrintJSON(map[string]any{
|
||||
"total": total,
|
||||
"failed": failed,
|
||||
"results": jsonResults,
|
||||
"success": failed == 0,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if failed > 0 && !continueOnErr {
|
||||
return firstErr
|
||||
|
||||
@@ -7,6 +7,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -30,7 +31,7 @@ func (*sheetStyleDryRunCaller) DryRun() bool { return true }
|
||||
func (*sheetStyleDryRunCaller) Fields() string { return "" }
|
||||
func (*sheetStyleDryRunCaller) JQ() string { return "" }
|
||||
|
||||
func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
@@ -56,14 +57,50 @@ func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
preview := output.String()
|
||||
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
|
||||
if format == "json" {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
|
||||
}
|
||||
results, _ := payload["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("JSON dry-run results = %#v", payload["results"])
|
||||
}
|
||||
entry, _ := results[0].(map[string]any)
|
||||
if entry["dryRun"] != true || entry["tool"] != "update_range" {
|
||||
t.Fatalf("JSON dry-run result = %#v", entry)
|
||||
}
|
||||
} else {
|
||||
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
if format == "json" && !strings.Contains(preview, `"dryRun": true`) {
|
||||
t.Fatalf("JSON dry-run summary missing typed dryRun evidence:\n%s", preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = forcedJSONWriteFailure{}
|
||||
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,17 @@ func isAuthTokenCiphertextFile(name string) bool {
|
||||
return name == legacyName ||
|
||||
(strings.HasPrefix(name, strings.TrimSuffix(legacyName, ".enc")+"_") && strings.HasSuffix(name, ".enc"))
|
||||
}
|
||||
|
||||
func platformRemoveAuthTokenEntries(service string) error {
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) && firstErr == nil {
|
||||
firstErr = fmt.Errorf("remove keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -20,3 +20,7 @@ package keychain
|
||||
func platformValidateAuthTokenEntries(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformRemoveAuthTokenEntries(service string) error {
|
||||
return registryRemoveAuthTokenEntries(service)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,13 @@ func ValidateAuthTokenEntries(service string) error {
|
||||
return platformValidateAuthTokenEntries(service)
|
||||
}
|
||||
|
||||
// RemoveAuthTokenEntries deletes the legacy token plus every organization and
|
||||
// identity scoped auth-token entry for service. Other keychain accounts are
|
||||
// preserved.
|
||||
func RemoveAuthTokenEntries(service string) error {
|
||||
return platformRemoveAuthTokenEntries(service)
|
||||
}
|
||||
|
||||
// Exists checks if an entry exists in the keychain.
|
||||
func Exists(service, account string) bool {
|
||||
val, err := Get(service, account)
|
||||
|
||||
@@ -146,6 +146,36 @@ func TestKeychainOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAuthTokenEntriesPreservesOtherAccounts(t *testing.T) {
|
||||
service := "test-service-" + t.Name()
|
||||
authAccounts := []string{
|
||||
AccountToken,
|
||||
AccountToken + ":corp-a",
|
||||
AccountToken + ":id:0123456789abcdef",
|
||||
}
|
||||
for _, account := range authAccounts {
|
||||
if err := Set(service, account, "secret"); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", account, err)
|
||||
}
|
||||
}
|
||||
const unrelated = "app-secret:client-a"
|
||||
if err := Set(service, unrelated, "preserve"); err != nil {
|
||||
t.Fatalf("Set(unrelated) error = %v", err)
|
||||
}
|
||||
|
||||
if err := RemoveAuthTokenEntries(service); err != nil {
|
||||
t.Fatalf("RemoveAuthTokenEntries() error = %v", err)
|
||||
}
|
||||
for _, account := range authAccounts {
|
||||
if Exists(service, account) {
|
||||
t.Fatalf("auth account %q still exists", account)
|
||||
}
|
||||
}
|
||||
if got, err := Get(service, unrelated); err != nil || got != "preserve" {
|
||||
t.Fatalf("unrelated account = %q, %v; want preserved", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationNoLegacyData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ package keychain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,6 +29,14 @@ import (
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
var registryDeleteValue = func(key registry.Key, name string) error {
|
||||
return key.DeleteValue(name)
|
||||
}
|
||||
|
||||
var registryOpenDeleteKey = func(path string, access uint32) (registry.Key, error) {
|
||||
return registry.OpenKey(registry.CURRENT_USER, path, access)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows backend: DPAPI + HKCU registry
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,11 +205,54 @@ func registrySet(service, account string, protected []byte) error {
|
||||
|
||||
func registryRemove(service, account string) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
|
||||
k, err := registryOpenDeleteKey(keyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return nil
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("registry open for delete failed: %w", err)
|
||||
}
|
||||
defer k.Close()
|
||||
_ = k.DeleteValue(valueNameForAccount(account))
|
||||
return deleteRegistryValue(k, valueNameForAccount(account))
|
||||
}
|
||||
|
||||
func deleteRegistryValue(key registry.Key, name string) error {
|
||||
if err := registryDeleteValue(key, name); err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("registry delete failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registryRemoveAuthTokenEntries(service string) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registryOpenDeleteKey(keyPath, registry.QUERY_VALUE|registry.SET_VALUE)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("registry open for auth token cleanup failed: %w", err)
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
names, err := k.ReadValueNames(-1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry list values failed: %w", err)
|
||||
}
|
||||
for _, name := range names {
|
||||
accountBytes, decodeErr := base64.RawURLEncoding.DecodeString(name)
|
||||
if decodeErr != nil {
|
||||
continue
|
||||
}
|
||||
account := string(accountBytes)
|
||||
if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") {
|
||||
continue
|
||||
}
|
||||
if err := k.DeleteValue(name); err != nil {
|
||||
return fmt.Errorf("registry delete auth token value failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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 windows
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func TestDeleteRegistryValuePropagatesFailure(t *testing.T) {
|
||||
originalDelete := registryDeleteValue
|
||||
failure := errors.New("delete failed")
|
||||
registryDeleteValue = func(registry.Key, string) error { return failure }
|
||||
t.Cleanup(func() { registryDeleteValue = originalDelete })
|
||||
|
||||
if err := deleteRegistryValue(0, "auth-token"); !errors.Is(err, failure) {
|
||||
t.Fatalf("deleteRegistryValue() error = %v, want %v", err, failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRemoveAuthTokenEntriesPropagatesOpenFailure(t *testing.T) {
|
||||
originalOpen := registryOpenDeleteKey
|
||||
failure := windows.ERROR_ACCESS_DENIED
|
||||
registryOpenDeleteKey = func(string, uint32) (registry.Key, error) {
|
||||
return 0, failure
|
||||
}
|
||||
t.Cleanup(func() { registryOpenDeleteKey = originalOpen })
|
||||
|
||||
if err := registryRemoveAuthTokenEntries("service"); !errors.Is(err, failure) {
|
||||
t.Fatalf("registryRemoveAuthTokenEntries() error = %v, want %v", err, failure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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 logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// AuthDebugEnv enables detailed authentication diagnostics when set to "1".
|
||||
const AuthDebugEnv = "DWS_DEBUG_AUTH"
|
||||
|
||||
// AuthDebug writes detailed authentication diagnostics only when explicitly enabled.
|
||||
func AuthDebug(message string, args ...any) {
|
||||
if os.Getenv(AuthDebugEnv) != "1" {
|
||||
return
|
||||
}
|
||||
slog.Debug(message, args...)
|
||||
}
|
||||
@@ -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 logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthDebugDisabledByDefault(t *testing.T) {
|
||||
t.Setenv(AuthDebugEnv, "")
|
||||
var output bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
|
||||
AuthDebug("auth.test", "user_id", "user")
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("AuthDebug() logged without %s=1: %s", AuthDebugEnv, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDebugEnabledOnlyByExplicitOne(t *testing.T) {
|
||||
for _, value := range []string{"0", "true", "yes"} {
|
||||
t.Run("disabled_"+value, func(t *testing.T) {
|
||||
t.Setenv(AuthDebugEnv, value)
|
||||
var output bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
|
||||
AuthDebug("auth.test")
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("AuthDebug() logged with %s=%q: %s", AuthDebugEnv, value, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("enabled", func(t *testing.T) {
|
||||
t.Setenv(AuthDebugEnv, "1")
|
||||
var output bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
|
||||
AuthDebug("auth.test", "user_id", "user")
|
||||
got := output.String()
|
||||
if !strings.Contains(got, `"msg":"auth.test"`) || !strings.Contains(got, `"user_id":"user"`) {
|
||||
t.Fatalf("AuthDebug() output = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -79,7 +79,8 @@ func (fl *FileLogger) Writer() io.Writer {
|
||||
return fl.writer
|
||||
}
|
||||
|
||||
// Close flushes and closes the underlying log file.
|
||||
// Close flushes and permanently closes the underlying log file. Later writes
|
||||
// fail with os.ErrClosed instead of reopening the file.
|
||||
func (fl *FileLogger) Close() error {
|
||||
if fl == nil || fl.writer == nil {
|
||||
return nil
|
||||
@@ -122,6 +123,7 @@ type rotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
written int64
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter {
|
||||
@@ -135,6 +137,9 @@ func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWrit
|
||||
func (w *rotatingWriter) open() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
|
||||
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
|
||||
if err != nil {
|
||||
@@ -153,6 +158,9 @@ func (w *rotatingWriter) open() error {
|
||||
func (w *rotatingWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
|
||||
// Attempt to recover if file was lost (e.g. rotation failed due to full disk).
|
||||
if w.file == nil {
|
||||
@@ -177,6 +185,9 @@ func (w *rotatingWriter) Write(p []byte) (int, error) {
|
||||
|
||||
// reopenLocked tries to reopen the log file. Caller must hold w.mu.
|
||||
func (w *rotatingWriter) reopenLocked() error {
|
||||
if w.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -199,6 +210,10 @@ func (w *rotatingWriter) close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
w.closed = true
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -121,3 +122,39 @@ func TestCloseOnNilLogger(t *testing.T) {
|
||||
t.Errorf("Close on nil should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFileLoggerCloseIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
fl := Setup(dir)
|
||||
logPath := filepath.Join(dir, logSubDir, logFileName)
|
||||
fl.Logger.Info("before close")
|
||||
if err := fl.Close(); err != nil {
|
||||
t.Fatalf("close logger: %v", err)
|
||||
}
|
||||
if err := os.Remove(logPath); err != nil {
|
||||
t.Fatalf("remove closed log file: %v", err)
|
||||
}
|
||||
if err := fl.writer.open(); !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("open after close = %v, want os.ErrClosed", err)
|
||||
}
|
||||
fl.writer.mu.Lock()
|
||||
err := fl.writer.reopenLocked()
|
||||
fl.writer.mu.Unlock()
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("reopen after close = %v, want os.ErrClosed", err)
|
||||
}
|
||||
|
||||
n, err := fl.Writer().Write([]byte("late write"))
|
||||
if n != 0 || !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("write after close = (%d, %v), want (0, os.ErrClosed)", n, err)
|
||||
}
|
||||
fl.Logger.Info("late structured write")
|
||||
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("closed logger recreated log file: %v", err)
|
||||
}
|
||||
if err := fl.Close(); err != nil {
|
||||
t.Fatalf("second close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user