Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dcc796f4c | ||
|
|
3e792b1c86 | ||
|
|
b9c822d49d | ||
|
|
f9b9b83f48 | ||
|
|
aa9e67e7c8 | ||
|
|
16ff02903a | ||
|
|
65d3f2959c | ||
|
|
cb3087ba9b | ||
|
|
5068cfdab8 | ||
|
|
3c81e5d47d | ||
|
|
d93925a892 | ||
|
|
faab9e0282 | ||
|
|
76d301268d | ||
|
|
7fddace8df | ||
|
|
99e5a3cceb | ||
|
|
7e31043875 | ||
|
|
55d7fbf59a | ||
|
|
c0f4d21c05 | ||
|
|
660c908585 | ||
|
|
377ebc5e85 | ||
|
|
076d77da8e | ||
|
|
2eca203e74 | ||
|
|
6e070a7e24 | ||
|
|
e867abd03c | ||
|
|
9d89965de9 | ||
|
|
41088bb965 | ||
|
|
8259116f15 | ||
|
|
9ec1fa0638 | ||
|
|
9afd3be79b | ||
|
|
67da5019e3 | ||
|
|
b0ded7deb8 | ||
|
|
22905fc41e | ||
|
|
ec9ff653fc | ||
|
|
876cf8e958 | ||
|
|
1c5ed6646e | ||
|
|
80549a80e0 | ||
|
|
883d416d83 | ||
|
|
25c70aeb24 | ||
|
|
6cfa9e3afb | ||
|
|
544a91e994 | ||
|
|
024d487a22 | ||
|
|
6b50cc41c5 | ||
|
|
11e50662f9 | ||
|
|
29abdb6e79 | ||
|
|
bc587ddd91 | ||
|
|
6196e2565e | ||
|
|
609d56305e | ||
|
|
e69a1084a7 | ||
|
|
978ee6e636 | ||
|
|
987c63d99c | ||
|
|
e565746fb7 | ||
|
|
4f76d7cb4c | ||
|
|
e94f230236 | ||
|
|
5242a0e1b1 | ||
|
|
c3e57b874b | ||
|
|
fa558372d2 | ||
|
|
ec9ae33a43 | ||
|
|
2b49a2f365 | ||
|
|
ae9b14e536 | ||
|
|
996c4ab250 | ||
|
|
cf36ccb46e | ||
|
|
361115956f | ||
|
|
91bd7c7802 | ||
|
|
5a8376ac0f | ||
|
|
31c984e18c | ||
|
|
69c0eb1a49 | ||
|
|
82e98d98a3 | ||
|
|
8a7e1c7be7 | ||
|
|
f59be6c19a | ||
|
|
c7c9a6f926 | ||
|
|
a0224e1cbd | ||
|
|
cd22cfb530 | ||
|
|
adc528c206 | ||
|
|
a2f1e79603 | ||
|
|
1adb4bc681 | ||
|
|
723c577484 | ||
|
|
766930f6e7 | ||
|
|
2e3311c955 | ||
|
|
1b4bb6b498 | ||
|
|
368e439280 | ||
|
|
8965fd2707 | ||
|
|
c0a7ad88a4 | ||
|
|
b62b1848aa | ||
|
|
5dd7f9abd3 | ||
|
|
eefee3c063 | ||
|
|
6f5d17335b |
@@ -5,6 +5,13 @@
|
||||
|
||||
## Verification
|
||||
|
||||
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
|
||||
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
|
||||
other pull request, mark the targeted check `N/A` and complete the applicable
|
||||
full-suite checks.
|
||||
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: AI Behavior Check
|
||||
name: Code Admission — AI Behavior
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior Policy Evaluator
|
||||
name: AI Behavior
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -21,75 +24,108 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior Check',
|
||||
context: 'AI Behavior',
|
||||
description,
|
||||
});
|
||||
|
||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||
|
||||
const labels = context.payload.pull_request.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
if (context.eventName === 'push') {
|
||||
await setStatus('success', 'Not applicable to the protected main push');
|
||||
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
try {
|
||||
const expectedHead = pullRequest.head.sha;
|
||||
const expectedBase = pullRequest.base.sha;
|
||||
const currentPull = async (phase) => {
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
|
||||
throw new Error(
|
||||
`Pull request revision changed during ${phase}: ` +
|
||||
`expected base/head ${expectedBase}/${expectedHead}, ` +
|
||||
`got ${pull.base.sha}/${pull.head.sha}`
|
||||
);
|
||||
}
|
||||
return pull;
|
||||
};
|
||||
|
||||
const before = await currentPull('pre-policy check');
|
||||
const labels = before.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
await currentPull('post-policy check');
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/ci/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
await setStatus('error', 'Could not evaluate the exact pull request revision');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
|
||||
+372
-142
@@ -18,48 +18,138 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
|
||||
steps:
|
||||
- name: Classify pull request scope
|
||||
id: classify
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let changelogChanged = false;
|
||||
let platformSensitive = context.eventName === 'push';
|
||||
let files = [];
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const expectedHead = context.payload.pull_request.head.sha;
|
||||
const expectedBase = context.payload.pull_request.base.sha;
|
||||
const assertCurrentRevision = async (phase) => {
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
|
||||
throw new Error(
|
||||
`Pull request revision changed during ${phase}: ` +
|
||||
`expected base/head ${expectedBase}/${expectedHead}, ` +
|
||||
`got ${pull.base.sha}/${pull.head.sha}`
|
||||
);
|
||||
}
|
||||
return pull;
|
||||
};
|
||||
|
||||
const before = await assertCurrentRevision('pre-classification');
|
||||
files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const after = await assertCurrentRevision('post-classification');
|
||||
if (
|
||||
before.changed_files !== files.length ||
|
||||
after.changed_files !== files.length
|
||||
) {
|
||||
throw new Error(
|
||||
`Pull request file list is incomplete: API reports ` +
|
||||
`${after.changed_files} changed files, pagination returned ${files.length}`
|
||||
);
|
||||
}
|
||||
|
||||
changelogOnly =
|
||||
files.length === 1 &&
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
changelogChanged = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/npm/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml'
|
||||
);
|
||||
platformSensitive = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
isPlatformSensitive(filename) ||
|
||||
isPlatformSensitive(previous_filename)
|
||||
);
|
||||
}
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('platform_sensitive', String(platformSensitive));
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
|
||||
.addRaw(`- Changed files: \`${files.length}\`\n`)
|
||||
.write();
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out repository
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify Test Package Plan
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: make test-plan
|
||||
|
||||
- name: Format Check
|
||||
run: |
|
||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: make format-check
|
||||
|
||||
- name: Go Vet
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go vet ./...
|
||||
|
||||
# golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25
|
||||
# - name: golangci-lint
|
||||
# uses: golangci/golangci-lint-action@v6
|
||||
# with:
|
||||
# version: v1.64.8
|
||||
# args: ./...
|
||||
|
||||
actionlint:
|
||||
name: Actionlint
|
||||
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: Check GitHub Actions workflows
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
@@ -79,45 +169,29 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' }}
|
||||
run: make build
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
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
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (release scripts)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -131,27 +205,96 @@ jobs:
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list release-scripts)"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-cross-platform:
|
||||
name: Test (cross-platform compile)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
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: Compile supported operating systems
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
for target in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
output="$RUNNER_TEMP/dws-${goos}-${goarch}"
|
||||
if [ "$goos" = windows ]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
printf 'compile %s/%s\n' "$goos" "$goarch"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||
go build -o "$output" ./cmd
|
||||
done
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs:
|
||||
- lint
|
||||
- test-race
|
||||
- test-release-scripts
|
||||
if: ${{ always() }}
|
||||
- test-cross-platform
|
||||
- test-darwin
|
||||
- test-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
RACE_RESULT: ${{ needs.test-race.result }}
|
||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
|
||||
CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }}
|
||||
DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
"macOS native:$DARWIN_RESULT" \
|
||||
"Windows native:$WINDOWS_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != skipped ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
exit
|
||||
fi
|
||||
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT"
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
@@ -160,10 +303,28 @@ jobs:
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
native_expected=skipped
|
||||
if [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
for native in \
|
||||
"macOS native:$DARWIN_RESULT" \
|
||||
"Windows native:$WINDOWS_RESULT"
|
||||
do
|
||||
name="${native%%:*}"
|
||||
result="${native#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "$native_expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -180,6 +341,8 @@ jobs:
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -202,6 +365,8 @@ jobs:
|
||||
|
||||
coverage-darwin:
|
||||
name: Coverage (macOS)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -244,6 +409,8 @@ jobs:
|
||||
|
||||
coverage-windows:
|
||||
name: Coverage (Windows)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -288,6 +455,8 @@ jobs:
|
||||
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -321,6 +490,8 @@ jobs:
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -358,6 +529,8 @@ jobs:
|
||||
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -421,20 +594,35 @@ jobs:
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
if: ${{ always() }}
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Verify coverage profile jobs
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
expected=success
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
expected=skipped
|
||||
elif [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
@@ -443,24 +631,42 @@ jobs:
|
||||
name="${profile%%:*}"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
if [ "$result" != "$expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
for native in \
|
||||
"macOS native:$DARWIN_RESULT" \
|
||||
"Windows native:$WINDOWS_RESULT"
|
||||
do
|
||||
name="${native%%:*}"
|
||||
result="${native#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
native_expected=skipped
|
||||
fi
|
||||
if [ "$result" != "$native_expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
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
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -478,33 +684,40 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
COVERAGE_TARGET: "80"
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
@@ -516,44 +729,114 @@ jobs:
|
||||
coverage.html
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
name: Policy
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify pull request merge revision
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
|
||||
echo "checked-out merge first parent does not match event base" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
|
||||
echo "checked-out merge second parent does not match event head" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Validate changed CHANGELOG content
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
merge_changelog_changed=false
|
||||
if git diff --no-ext-diff --find-renames --name-status \
|
||||
"$PR_BASE_SHA" HEAD |
|
||||
awk -F '\t' '
|
||||
{
|
||||
for (field = 2; field <= NF; field++) {
|
||||
if ($field == "CHANGELOG.md") found = 1
|
||||
}
|
||||
}
|
||||
END { exit !found }
|
||||
'
|
||||
then
|
||||
merge_changelog_changed=true
|
||||
fi
|
||||
test "$merge_changelog_changed" = "$CLASSIFIED_CHANGELOG_CHANGED" || {
|
||||
echo "Files API and synthetic merge tree disagree on CHANGELOG scope" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$merge_changelog_changed" != true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode=--content-only
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
mode=--fast-path
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
"$mode" "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: |
|
||||
echo "Only the base-equivalent CHANGELOG validator ran; full Policy resumes on main." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Policy
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make policy
|
||||
|
||||
interface-integrity:
|
||||
name: Interface Integrity
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
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
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -578,6 +861,7 @@ jobs:
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
@@ -587,143 +871,89 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make skill-command-integrity
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make cli-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
name: Mock MCP
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check HTTP and stdio MCP transport
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
name: Edition
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs:
|
||||
- lint
|
||||
- actionlint
|
||||
- test
|
||||
- test-darwin
|
||||
- test-windows
|
||||
- coverage
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
- policy
|
||||
- interface-integrity
|
||||
- cli-smoke
|
||||
- mock-mcp-smoke
|
||||
- edition-tests
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
ACTIONLINT_RESULT: ${{ needs.actionlint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Actionlint:$ACTIONLINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
|
||||
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
# Trigger internal GitLab CI pipeline via webhook.
|
||||
# WUKONG_TRIGGER_TOKEN is a repository secret.
|
||||
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
|
||||
"${{ secrets.WUKONG_TRIGGER_URL }}"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Multi Profile E2E
|
||||
name: Main Integration — 主干集成
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -14,7 +15,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
name: Multi-profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "### Multi-profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Main Integration — Wukong Overlay
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
env:
|
||||
UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }}
|
||||
WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }}
|
||||
run: |
|
||||
if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=$WUKONG_TRIGGER_TOKEN" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \
|
||||
"$WUKONG_TRIGGER_URL"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
+1930
-202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
name: Withdraw release
|
||||
run-name: Withdraw ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Public, single-line withdrawal reason (8-300 characters)"
|
||||
required: true
|
||||
type: string
|
||||
confirmation:
|
||||
description: "Type WITHDRAW followed by a space and the exact version"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Share the publication lock with release.yml. A withdrawal and a publication
|
||||
# must never mutate channel pointers concurrently.
|
||||
concurrency:
|
||||
group: dws-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: Withdraw release from every distribution channel
|
||||
environment: release-withdrawal
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Verify withdrawal environment protection
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const response = await github.request(
|
||||
"GET /repos/{owner}/{repo}/environments/{environment_name}",
|
||||
{ owner, repo, environment_name: "release-withdrawal" },
|
||||
);
|
||||
const reviewerRule = response.data.protection_rules.find(
|
||||
(rule) => rule.type === "required_reviewers",
|
||||
);
|
||||
if (
|
||||
!reviewerRule ||
|
||||
reviewerRule.prevent_self_review !== true ||
|
||||
!Array.isArray(reviewerRule.reviewers) ||
|
||||
reviewerRule.reviewers.length === 0
|
||||
) {
|
||||
core.setFailed("release-withdrawal must require a reviewer and prevent self-review");
|
||||
return;
|
||||
}
|
||||
if (response.data.deployment_branch_policy?.protected_branches !== true) {
|
||||
core.setFailed("release-withdrawal must allow only protected branches");
|
||||
}
|
||||
if (response.data.can_admins_bypass !== false) {
|
||||
core.setFailed("release-withdrawal must not allow administrator bypass");
|
||||
}
|
||||
|
||||
- name: Require the exact current official default-branch commit
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.setFailed("release withdrawal accepts workflow_dispatch only");
|
||||
return;
|
||||
}
|
||||
if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) {
|
||||
core.setFailed(`release withdrawal is restricted to ${expectedRepository}`);
|
||||
return;
|
||||
}
|
||||
if (context.ref !== `refs/heads/${defaultBranch}`) {
|
||||
core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`);
|
||||
return;
|
||||
}
|
||||
const branch = await github.rest.git.getRef({
|
||||
...context.repo,
|
||||
ref: `heads/${defaultBranch}`,
|
||||
});
|
||||
if (branch.data.object.sha !== context.sha) {
|
||||
core.setFailed(
|
||||
`default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`,
|
||||
);
|
||||
}
|
||||
|
||||
- name: Check out trusted withdrawal tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for npm channel withdrawal
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Withdraw immutable release and roll back channels
|
||||
id: withdrawal
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WITHDRAW_VERSION: ${{ inputs.version }}
|
||||
WITHDRAW_REASON: ${{ inputs.reason }}
|
||||
WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }}
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
run: |
|
||||
./scripts/release/withdraw-release.sh \
|
||||
"$WITHDRAW_VERSION" \
|
||||
"$WITHDRAW_REASON" \
|
||||
"$WITHDRAW_CONFIRMATION"
|
||||
|
||||
- name: Report withdrawal boundary
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
RESULT: ${{ steps.withdrawal.outcome }}
|
||||
run: |
|
||||
{
|
||||
echo "### Release withdrawal: ${VERSION}"
|
||||
echo
|
||||
echo "- Workflow result: ${RESULT}"
|
||||
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
|
||||
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
|
||||
echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
|
||||
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
|
||||
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -6,6 +6,46 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.53-beta.6] - 2026-07-21
|
||||
|
||||
This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tag-bound optional OSS release mirror** — Official cloud Release runs no longer block GitHub, npm, and Homebrew delivery when an OSS bucket has not been provisioned. Cloud tags immutably record `OSS-Mirror: enabled|deferred`; publication, repair, and withdrawal consume that sealed policy instead of the current repository variable. Enabled releases remain fail-closed, while deferred releases skip the nonexistent channel and cannot be backfilled without a future audited repair proof.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata.
|
||||
|
||||
## [1.0.53-beta.5] - 2026-07-21
|
||||
|
||||
This beta validates long-running access-token recovery and the faster, recoverable guarded release path introduced after v1.0.53-beta.4.
|
||||
|
||||
### 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
|
||||
|
||||
- **Long-running event authentication recovery** — personal and portal event streams resolve the current access token for every ticket request, refresh a server-rejected token with compare-and-refresh semantics, and reconnect with backoff when refresh is temporarily blocked by network failures, rate limits, or 5xx responses.
|
||||
- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.”
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -36,8 +36,8 @@ Common repository checks already used here include:
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
go test ./...
|
||||
make test
|
||||
make test-plan
|
||||
make lint
|
||||
bash test/scripts/run_all_tests.sh --jobs 8
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-command-surface.sh --strict
|
||||
./scripts/release/verify-package-managers.sh
|
||||
|
||||
@@ -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.2"
|
||||
version "1.0.53-beta.4"
|
||||
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.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
|
||||
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.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip"
|
||||
sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
|
||||
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
GO ?= go
|
||||
DWS_PACKAGE_VERSION ?= 0.0.0-test
|
||||
REMOTE ?=
|
||||
PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -14,13 +16,15 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@@ -45,13 +49,32 @@ rebuild:
|
||||
@./scripts/dev/build.sh
|
||||
|
||||
test:
|
||||
@./test/scripts/run_all_tests.sh
|
||||
@DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./...
|
||||
|
||||
test-plan:
|
||||
@./scripts/ci/test-packages.sh verify
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
format-check:
|
||||
@set -eu; \
|
||||
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
|
||||
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
printf '%s\n' "$$unformatted"; \
|
||||
printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
fmt:
|
||||
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
@set -eu; \
|
||||
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
|
||||
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
|
||||
@@ -495,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
|
||||
|
||||
@@ -503,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 |
|
||||
|
||||
@@ -489,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
|
||||
|
||||
@@ -497,6 +500,8 @@ dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
|
||||
@@ -43,3 +43,38 @@
|
||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
||||
|
||||
## Quality Pipeline
|
||||
|
||||
Quality enforcement is layered so a pull request receives fast, deterministic
|
||||
admission feedback without pretending that downstream integration has already
|
||||
run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CA["CI"]
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
C["Coverage"]
|
||||
P["Policy"]
|
||||
E["Edition"]
|
||||
I["Interface Integrity"]
|
||||
A["AI Behavior"]
|
||||
S["CLI Smoke"]
|
||||
M["Mock MCP"]
|
||||
end
|
||||
CA --> CA_CHECKS
|
||||
CA_CHECKS --> MAIN["Protected main"]
|
||||
MAIN --> MP["Main Integration — 主干集成<br/>Multi-profile E2E"]
|
||||
MAIN --> PLATFORM["Risk-selected / release native platform validation"]
|
||||
MP --> RELEASE["Release delivery"]
|
||||
PLATFORM --> RELEASE
|
||||
```
|
||||
|
||||
Complete Multi-profile E2E and the ordinary full native-platform matrix are
|
||||
downstream of PR admission. PRs still run primary-environment assurance and
|
||||
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
|
||||
release changes additionally select native platform tests before merge. See
|
||||
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
|
||||
contract.
|
||||
|
||||
+62
-6
@@ -65,21 +65,77 @@ 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.
|
||||
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
is provisioned; GitHub, npm, and Homebrew delivery can then complete without
|
||||
running the OSS step. Once enabled, missing credentials, an invalid Bucket, or
|
||||
an upload failure remains fail-closed. The cloud tag immutably records the
|
||||
decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume
|
||||
that sealed value instead of the variable's later state. Deferred releases
|
||||
cannot use `repair_oss_version`; enabling OSS applies to later release tags
|
||||
until an audited immutable repair marker is implemented.
|
||||
|
||||
If an immutable GitHub Release and npm package were delivered but an enabled
|
||||
downstream China mirror failed, dispatch the normal `Release` workflow from the
|
||||
protected default branch with exactly one of `repair_gitee_version` or
|
||||
`repair_oss_version`. Channel repair accepts a fully successful exact release,
|
||||
or a failed exact-tag run only when its latest attempt completed the release
|
||||
contract, build, Apple signature, immutable GitHub publication, and npm
|
||||
delivery checks for the exact tagged commit. OSS repair additionally requires
|
||||
the tag's sealed policy to be `enabled`. It then downloads and re-verifies the
|
||||
immutable assets before invoking only the selected mirror. For a failed
|
||||
release, an OSS repair requires the OSS step itself to be the recorded failure.
|
||||
A Gitee repair accepts either a failed Gitee job or a Gitee job that was
|
||||
skipped behind that OSS failure; the latter is an explicit Gitee backfill and
|
||||
does not claim that OSS has been repaired. Gitee repair requires `GITEE_TOKEN`,
|
||||
`GITEE_USER`, and `GITEE_REPO`; OSS repair requires `OSS_ACCESS_KEY_ID`,
|
||||
`OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` (with optional
|
||||
`OSS_PREFIX`) as Actions secrets. Missing credentials fail the selected repair
|
||||
closed.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
+133
-108
@@ -1,133 +1,158 @@
|
||||
# Pull request quality gates
|
||||
# CI — PR 合入门禁
|
||||
|
||||
The repository defines five focused checks in addition to its existing CI:
|
||||
The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
- **Interface Integrity** enforces backwards compatibility. Every historical
|
||||
command path and alias must still resolve, every historical command must
|
||||
still render `-h`, and historical flags must keep their type and shorthand.
|
||||
New commands, aliases, and flags are allowed. The same job compares the full
|
||||
complete `dws schema --all` contract with the PR merge-base, blocking removed
|
||||
products/tools/parameters, incompatible parameter or interface mappings,
|
||||
constraint drift, and safety-semantic drift. It also checks that executable
|
||||
`dws ...` references in `skills/**/*.md` resolve to real commands.
|
||||
Help compatibility covers command/alias/flag spelling, flag type and
|
||||
shorthand; descriptive prose may evolve without breaking the gate.
|
||||
- **Coverage** runs unit tests on every pull request and prints both overall and
|
||||
changed-code statement coverage. During the migration to the 80% repository
|
||||
target, overall coverage may not regress from a profile generated from the
|
||||
merge-base with the same test command, while changed production Go
|
||||
statements must meet 80%. Linux, Windows, and macOS each generate a native
|
||||
coverage profile for changed packages and enforce the threshold against
|
||||
changed files buildable on that platform, so build-tagged source cannot be
|
||||
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
|
||||
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
|
||||
fails when the checked-in development fixture is stale.
|
||||
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
|
||||
(`Initialize -> ListTools -> CallTool`).
|
||||
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes,
|
||||
including policy implementations and the checked-in Interface fixture.
|
||||
It uses `pull_request_target` without checking out PR code, so the policy
|
||||
cannot be bypassed by changing the workflow in the same pull request. The
|
||||
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
|
||||
GitHub rulesets can require it.
|
||||
| Required context | Contract |
|
||||
|---|---|
|
||||
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||
| `Coverage` | Overall non-regression and 100% changed-code coverage |
|
||||
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||
| `Edition` | Edition contract tests |
|
||||
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||
| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` |
|
||||
| `CLI Smoke` | Offline help for every public top-level command |
|
||||
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
|
||||
|
||||
## Running the compatibility gates
|
||||
The workflow display name is `CI`. Parallel helper
|
||||
jobs may implement `Test` and `Coverage`, but they are not ruleset contexts.
|
||||
Do not require an aggregate alias or a downstream integration check in place of
|
||||
the nine contracts above.
|
||||
|
||||
Run:
|
||||
`AI Behavior` is evaluated by a `pull_request_target` workflow that never
|
||||
checks out or executes PR code. It writes the exact `AI Behavior` status to the
|
||||
current PR head. Its Files API read is bracketed by base/head revision checks,
|
||||
so a synchronize race fails closed. The same workflow supplies a successful
|
||||
`AI Behavior` check run on protected `main` pushes for release governance.
|
||||
|
||||
## Exact CHANGELOG-only fast path
|
||||
|
||||
A pull request qualifies only when GitHub reports exactly one changed file,
|
||||
that file is an in-place modification of `CHANGELOG.md`, and the base and head
|
||||
both retain it as a regular non-executable `100644` blob. Add, delete, rename,
|
||||
symlink, executable-mode, and second-file changes do not qualify.
|
||||
|
||||
`Lint` classifies the Files API result only after verifying that the API's base
|
||||
and head equal the event revision both before and after pagination. `Policy`
|
||||
checks out GitHub's PR merge ref and verifies its parents:
|
||||
|
||||
```text
|
||||
HEAD^1 = pull_request.base.sha
|
||||
HEAD^2 = pull_request.head.sha
|
||||
```
|
||||
|
||||
It then runs:
|
||||
|
||||
```sh
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
--fast-path "$PR_BASE_SHA" HEAD
|
||||
```
|
||||
|
||||
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
|
||||
its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
versions. Validation targets the synthetic merge tree, not the feature-branch
|
||||
tree, so a stale branch cannot supply an older validator or combine with newer
|
||||
base notes into an invalid final CHANGELOG.
|
||||
|
||||
All nine admission contexts are still emitted and must succeed. Expensive
|
||||
implementation helpers are skipped; the named contexts record that their code
|
||||
surface is unaffected. After merge, the protected `main` push executes the
|
||||
full admission suite.
|
||||
|
||||
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
|
||||
content contract in `Policy` with `--content-only`. That mode permits the
|
||||
second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
|
||||
## Platform and downstream boundaries
|
||||
|
||||
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
|
||||
checks. Full native macOS/Windows tests and platform coverage run on a PR only
|
||||
when its diff touches auth, keychain, OS-specific Go files, installers,
|
||||
packaging, Formulae, or release automation. Protected `main` pushes run the
|
||||
complete native matrix.
|
||||
|
||||
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
|
||||
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
|
||||
an explicit manual dispatch). A failing downstream run remains a real
|
||||
regression and must be repaired, but it must not be represented by a synthetic
|
||||
successful PR check.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> ADMISSION["CI"]
|
||||
ADMISSION --> L["Lint"]
|
||||
ADMISSION --> T["Test"]
|
||||
ADMISSION --> C["Coverage"]
|
||||
ADMISSION --> P["Policy"]
|
||||
ADMISSION --> E["Edition"]
|
||||
ADMISSION --> I["Interface Integrity"]
|
||||
ADMISSION --> A["AI Behavior"]
|
||||
ADMISSION --> S["CLI Smoke"]
|
||||
ADMISSION --> M["Mock MCP"]
|
||||
ADMISSION --> MAIN["Protected main"]
|
||||
MAIN --> NATIVE["Full native platform matrix"]
|
||||
MAIN --> E2E["Multi-profile E2E"]
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
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:
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
root=$(pwd)
|
||||
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
|
||||
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 -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 -p 1 \
|
||||
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
The native-platform target likewise expects `PROFILE` to have already been
|
||||
generated on that operating system. CI owns those generation steps; copying
|
||||
only either enforcement command into a clean checkout is intentionally an
|
||||
incomplete invocation.
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||
native profiles before the aggregate `Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
|
||||
non-regression check. Candidate and baseline profiles are evaluated by the
|
||||
same block-deduplicating checker; supporting policy and shortcut profiles
|
||||
contribute to changed-code coverage only. The checked-in badge is presentation
|
||||
only and is never read as a gate input.
|
||||
|
||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
||||
`main`. The candidate branch cannot bless a breaking change by editing a
|
||||
fixture. Schema additions are allowed; historical products, tools, parameters,
|
||||
parameter mappings, positional execution fields, constraints, and safety
|
||||
semantics remain protected. Positional descriptions are documentation and may
|
||||
change without breaking compatibility.
|
||||
|
||||
`make update-interface-baseline` still extends the local checked-in Interface
|
||||
fixture used by `make interface-integrity`. Updates are monotonic: they add new
|
||||
commands and flags without removing history.
|
||||
|
||||
For an intentional compatibility reset at a major-version boundary, run
|
||||
`make reset-interface-baseline`. This replaces all CLI compatibility history
|
||||
with the current command tree and must receive explicit human review.
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
Create a ruleset for `main` that requires pull requests and code-owner review,
|
||||
then mark these aggregate status checks as required:
|
||||
The `main` quality ruleset must enable strict required-status-check policy
|
||||
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
|
||||
`main` advances. It must require these exact contexts and no legacy aliases:
|
||||
|
||||
- `CI Gate`
|
||||
- `Multi Profile E2E`
|
||||
- `AI Behavior Check`
|
||||
- `Lint`
|
||||
- `Test`
|
||||
- `Coverage`
|
||||
- `Policy`
|
||||
- `Edition`
|
||||
- `Interface Integrity`
|
||||
- `AI Behavior`
|
||||
- `CLI Smoke`
|
||||
- `Mock MCP`
|
||||
|
||||
`CI Gate` fails closed unless every first-layer CI job succeeds, including
|
||||
lint, tests, native Linux/Windows/macOS coverage, policy,
|
||||
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
|
||||
check keeps repository rules stable when an internal job is renamed or split.
|
||||
|
||||
The `ai-generated` label must be applied by the PR-creation automation or by a
|
||||
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
|
||||
AI-generated code.
|
||||
Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||
alias. Update ruleset contexts only after the new names have appeared on the
|
||||
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||
unproducible required context.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+99
-17
@@ -1,10 +1,54 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
|
||||
## 日常只用一个入口
|
||||
## 推荐入口:GitHub 云端发布
|
||||
|
||||
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
|
||||
|
||||
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
## 自动版本规则
|
||||
|
||||
- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。
|
||||
- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。
|
||||
- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。
|
||||
- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。
|
||||
- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。
|
||||
|
||||
## 全平台撤回与回滚
|
||||
|
||||
已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写:
|
||||
|
||||
- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。
|
||||
- `reason`:8–300 字符的单行公开原因。
|
||||
- `confirmation`:精确输入 `WITHDRAW <version>`,例如 `WITHDRAW v1.0.53`。
|
||||
|
||||
该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行:
|
||||
|
||||
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
|
||||
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
|
||||
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
|
||||
4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
|
||||
|
||||
GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。
|
||||
|
||||
撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。
|
||||
|
||||
撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。
|
||||
|
||||
撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。
|
||||
|
||||
## 兼容入口:本地发布
|
||||
|
||||
安装发布 Skill 后直接运行:
|
||||
|
||||
@@ -29,7 +73,7 @@ main 上的候选代码 + beta CHANGELOG
|
||||
→ vX.Y.Z(正式发布)
|
||||
```
|
||||
|
||||
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
|
||||
## 预发发布
|
||||
|
||||
@@ -45,13 +89,13 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -68,7 +112,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
@@ -86,28 +130,66 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
|
||||
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
|
||||
repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、
|
||||
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
|
||||
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
|
||||
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
|
||||
不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或
|
||||
stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧
|
||||
attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release
|
||||
workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
|
||||
## 既有 tag 的紧急恢复
|
||||
|
||||
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
|
||||
云端封板或本地 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 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- `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 中的固定证据。
|
||||
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 单独配置 `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 发布不受影响。
|
||||
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type tokenManagerSnapshotProvider struct {
|
||||
load func() (*authpkg.TokenData, error)
|
||||
}
|
||||
|
||||
func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) {
|
||||
data, err := p.load()
|
||||
if err != nil || data == nil {
|
||||
return "", err
|
||||
}
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
|
||||
func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) {
|
||||
return p.load()
|
||||
}
|
||||
|
||||
type tokenManagerLegacyGetter struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g tokenManagerLegacyGetter) GetToken() (string, string, error) {
|
||||
return g.token, "file", g.err
|
||||
}
|
||||
|
||||
func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) {
|
||||
t.Helper()
|
||||
oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager
|
||||
oldEdition := edition.Get()
|
||||
edition.Override(&edition.Hooks{})
|
||||
newAccessTokenProvider = func(string) accessTokenGetter {
|
||||
return tokenManagerSnapshotProvider{load: load}
|
||||
}
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter {
|
||||
return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
token := "token-a"
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
})
|
||||
|
||||
manager := NewTokenManager()
|
||||
first, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || first.AccessToken != "token-a" {
|
||||
t.Fatalf("first token = %#v, %v", first, err)
|
||||
}
|
||||
second, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 {
|
||||
t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load())
|
||||
}
|
||||
|
||||
token = "token-b"
|
||||
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 {
|
||||
t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: "token"}, nil
|
||||
})
|
||||
manager := NewTokenManager()
|
||||
for range 2 {
|
||||
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
})
|
||||
manager := NewTokenManager()
|
||||
for range 2 {
|
||||
if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" {
|
||||
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
})
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(string) ([]byte, error) { return nil, nil },
|
||||
TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||
return fallback()
|
||||
},
|
||||
})
|
||||
manager := NewTokenManager()
|
||||
for range 2 {
|
||||
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
release := make(chan struct{})
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
})
|
||||
manager := NewTokenManager()
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
errs := make(chan error, workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := manager.Get(context.Background(), configDir, "")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
for calls.Load() == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
want := errors.New("keychain permission denied")
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want })
|
||||
_, err := NewTokenManager().Get(context.Background(), configDir, "")
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v, want cause %v", err, want)
|
||||
}
|
||||
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("provider failure was misclassified as missing credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) {
|
||||
missing := tokenResolutionError(authpkg.ErrTokenDataNotFound)
|
||||
var typed interface{ Unwrap() error }
|
||||
if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("missing error = %v", missing)
|
||||
}
|
||||
want := errors.New("decrypt failed")
|
||||
if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("storage error = %v", got)
|
||||
}
|
||||
if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) {
|
||||
t.Fatalf("cancellation = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,61 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const accessTokenRefreshWindow = 5 * time.Minute
|
||||
|
||||
type legacyTokenGetter interface {
|
||||
GetToken() (string, string, error)
|
||||
}
|
||||
|
||||
type accessTokenSnapshotGetter interface {
|
||||
GetTokenSnapshot(context.Context) (*authpkg.TokenData, error)
|
||||
}
|
||||
|
||||
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||
// Refresh-token material never leaves the auth package.
|
||||
type AccessTokenSnapshot struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
}
|
||||
|
||||
type tokenManagerKey struct {
|
||||
configDir string
|
||||
profile string
|
||||
}
|
||||
|
||||
type tokenManagerEntry struct {
|
||||
mu sync.Mutex
|
||||
snapshot AccessTokenSnapshot
|
||||
revision string
|
||||
}
|
||||
|
||||
// TokenManager is the only process cache for user access tokens. Cache entries
|
||||
// are isolated by config directory and profile, expiry-aware, and invalidated
|
||||
// by the credential publication marker written by auth storage.
|
||||
type TokenManager struct {
|
||||
mu sync.Mutex
|
||||
entries map[tokenManagerKey]*tokenManagerEntry
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTokenManager() *TokenManager {
|
||||
return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now}
|
||||
}
|
||||
|
||||
var runtimeTokenManager = NewTokenManager()
|
||||
|
||||
var (
|
||||
newAccessTokenProvider = func(configDir string) accessTokenGetter {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, discard)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
return provider
|
||||
}
|
||||
@@ -44,64 +86,218 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||
provider := newAccessTokenProvider(configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
|
||||
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
|
||||
// the active edition config directory, the same process-cached path as MCP is used.
|
||||
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
|
||||
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
||||
if t := strings.TrimSpace(explicitToken); t != "" {
|
||||
return t, nil
|
||||
// Get resolves an access token for the active runtime profile.
|
||||
func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) {
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil
|
||||
}
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
|
||||
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
|
||||
return tok, nil
|
||||
key := tokenManagerKey{
|
||||
configDir: canonicalTokenConfigDir(configDir),
|
||||
profile: strings.TrimSpace(authpkg.RuntimeProfile()),
|
||||
}
|
||||
entry := m.entry(key)
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if m != nil && m.now != nil {
|
||||
now = m.now()
|
||||
}
|
||||
revision, present, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision {
|
||||
return entry.snapshot, nil
|
||||
}
|
||||
|
||||
// Treat the marker and credential as one optimistic snapshot. A concurrent
|
||||
// login/refresh between the reads causes a retry instead of caching stale A
|
||||
// under the publication marker for B.
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile)
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
if beforePresent != afterPresent || beforeRevision != afterRevision {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(snapshot.AccessToken) == "" {
|
||||
return AccessTokenSnapshot{}, noCredentialsError()
|
||||
}
|
||||
if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" {
|
||||
entry.snapshot = snapshot
|
||||
entry.revision = afterRevision
|
||||
} else {
|
||||
entry.snapshot = AccessTokenSnapshot{}
|
||||
entry.revision = ""
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
tok, err := resolveAccessTokenFromDir(ctx, configDir)
|
||||
return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials")
|
||||
}
|
||||
|
||||
func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.entries == nil {
|
||||
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
|
||||
}
|
||||
entry := m.entries[key]
|
||||
if entry == nil {
|
||||
entry = &tokenManagerEntry{}
|
||||
m.entries[key] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (m *TokenManager) Invalidate() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
|
||||
hooks := edition.Get()
|
||||
opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil
|
||||
provider := hooks.TokenProvider
|
||||
if provider == nil {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
// Opaque edition storage hooks have no publication-revision contract.
|
||||
// Resolve them on every logical request instead of caching a token that
|
||||
// may be replaced outside the default auth store.
|
||||
if opaqueStorage {
|
||||
snapshot.ExpiresAt = time.Time{}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
var fallbackSnapshot AccessTokenSnapshot
|
||||
var fallbackCalled bool
|
||||
token, err := provider(ctx, func() (string, error) {
|
||||
fallbackCalled = true
|
||||
var fallbackErr error
|
||||
fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
|
||||
if fallbackErr != nil {
|
||||
return "", fallbackErr
|
||||
}
|
||||
return fallbackSnapshot.AccessToken, nil
|
||||
})
|
||||
if err != nil {
|
||||
return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err)
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return AccessTokenSnapshot{}, noCredentialsError()
|
||||
}
|
||||
if fallbackCalled && token == fallbackSnapshot.AccessToken {
|
||||
if opaqueStorage {
|
||||
fallbackSnapshot.ExpiresAt = time.Time{}
|
||||
}
|
||||
return fallbackSnapshot, nil
|
||||
}
|
||||
// Edition providers expose no lifetime metadata, so resolve them on every
|
||||
// logical request instead of recreating a process-lifetime string cache.
|
||||
return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil
|
||||
}
|
||||
|
||||
func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
|
||||
provider := newAccessTokenProvider(configDir)
|
||||
if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok {
|
||||
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
return resolveLegacyToken(configDir, err)
|
||||
}
|
||||
|
||||
token, err := provider.GetAccessToken(ctx)
|
||||
if err == nil && strings.TrimSpace(token) != "" {
|
||||
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
return resolveLegacyToken(configDir, err)
|
||||
}
|
||||
|
||||
func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) {
|
||||
token, source, err := newLegacyTokenManager(configDir).GetToken()
|
||||
if err == nil && strings.TrimSpace(token) != "" {
|
||||
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return AccessTokenSnapshot{}, err
|
||||
}
|
||||
if oauthErr != nil {
|
||||
return AccessTokenSnapshot{}, oauthErr
|
||||
}
|
||||
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
|
||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tok != "" {
|
||||
return tok, nil
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through
|
||||
// the same TokenManager used by MCP tool calls.
|
||||
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool {
|
||||
return strings.TrimSpace(snapshot.AccessToken) != "" &&
|
||||
!snapshot.ExpiresAt.IsZero() &&
|
||||
now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow))
|
||||
}
|
||||
|
||||
func canonicalTokenConfigDir(configDir string) string {
|
||||
if absolute, err := filepath.Abs(configDir); err == nil {
|
||||
return filepath.Clean(absolute)
|
||||
}
|
||||
return filepath.Clean(configDir)
|
||||
}
|
||||
|
||||
func noCredentialsError() error {
|
||||
if edition.Get().IsEmbedded {
|
||||
return fmt.Errorf("认证信息已失效,请重新认证")
|
||||
return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound)
|
||||
}
|
||||
return fmt.Errorf("no credentials found, run: dws auth login")
|
||||
return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
|
||||
return g.token, g.err
|
||||
}
|
||||
|
||||
func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) {
|
||||
return g.token, g.err
|
||||
}
|
||||
|
||||
type fakeLegacyTokenGetter struct {
|
||||
token string
|
||||
err error
|
||||
@@ -212,14 +216,16 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
||||
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||
t.Fatalf("decryption error = %v", err)
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
|
||||
newAccessTokenProvider = func(string) accessTokenGetter {
|
||||
return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound}
|
||||
}
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
||||
t.Fatalf("legacy token = %q, %v", got, err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" {
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
@@ -246,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
|
||||
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
|
||||
oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider
|
||||
oldStop := stopStdio
|
||||
t.Cleanup(func() {
|
||||
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
|
||||
loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory
|
||||
stopStdio = oldStop
|
||||
stdioMu.Lock()
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
@@ -257,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
_ = oldFactory(t.TempDir())
|
||||
markAccessTokenStale = func(string) error { return fail }
|
||||
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail }
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
|
||||
t.Fatalf("mark stale error = %v", err)
|
||||
t.Fatalf("load rejected token error = %v", err)
|
||||
}
|
||||
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "rejected"}, nil
|
||||
}
|
||||
markAccessTokenStale = func(string) error { return nil }
|
||||
for _, tc := range []struct {
|
||||
getter fakeAccessTokenGetter
|
||||
want string
|
||||
@@ -270,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
|
||||
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
|
||||
{getter: fakeAccessTokenGetter{token: " refreshed "}},
|
||||
} {
|
||||
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
|
||||
newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter }
|
||||
got, err := ForceRefreshAccessToken(context.Background(), "config")
|
||||
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||
t.Fatalf("refresh error = %v, want %q", err, tc.want)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||
)
|
||||
|
||||
// authRetryingKey marks a context that has already attempted one
|
||||
@@ -23,8 +32,35 @@ import (
|
||||
// to the user instead.
|
||||
type authRetryingKeyType struct{}
|
||||
|
||||
type authRefreshFailureError struct {
|
||||
rejection error
|
||||
refresh error
|
||||
}
|
||||
|
||||
func (e *authRefreshFailureError) Error() string {
|
||||
return "automatic access token refresh failed"
|
||||
}
|
||||
|
||||
func (e *authRefreshFailureError) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{e.rejection, e.refresh}
|
||||
}
|
||||
|
||||
var authRetryingKey = authRetryingKeyType{}
|
||||
|
||||
var (
|
||||
runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken
|
||||
runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error)
|
||||
)
|
||||
|
||||
func init() {
|
||||
runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthRetrying reports whether the current context is already inside an
|
||||
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
||||
func IsAuthRetrying(ctx context.Context) bool {
|
||||
@@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func withAuthRetrying(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, authRetryingKey, true)
|
||||
}
|
||||
|
||||
func authRefreshLogger() *slog.Logger {
|
||||
if logger := FileLoggerInstance(); logger != nil {
|
||||
return logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool {
|
||||
if r == nil || hasPluginAuth {
|
||||
return false
|
||||
}
|
||||
return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == ""
|
||||
}
|
||||
|
||||
// retryAuthRefreshRequired consumes only the explicit edition marker. It does
|
||||
// not infer retryability from free text, generic auth categories, HTTP 403, or
|
||||
// ordinary business errors.
|
||||
func (r *runtimeRunner) retryAuthRefreshRequired(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
invocation executor.Invocation,
|
||||
rejectedAccessToken string,
|
||||
markerErr error,
|
||||
hasPluginAuth bool,
|
||||
) (executor.Result, error, bool) {
|
||||
marker, marked := authretry.As(markerErr)
|
||||
if !marked {
|
||||
return executor.Result{}, nil, false
|
||||
}
|
||||
cause := marker.Cause
|
||||
if cause == nil {
|
||||
cause = markerErr
|
||||
}
|
||||
|
||||
// Explicit --token and plugin credentials are not backed by the default
|
||||
// OAuth refresh store. Preserve the overlay cause without mutating an
|
||||
// unrelated persisted login.
|
||||
if !r.managesRuntimeOAuth(hasPluginAuth) {
|
||||
return executor.Result{}, cause, true
|
||||
}
|
||||
if IsAuthRetrying(ctx) {
|
||||
authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted",
|
||||
"product", invocation.CanonicalProduct,
|
||||
"tool", invocation.Tool,
|
||||
)
|
||||
return executor.Result{}, cause, true
|
||||
}
|
||||
|
||||
if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil {
|
||||
// Keep every log credential-safe. The returned error chain retains the
|
||||
// complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not
|
||||
// serialize an OAuth response body or other attacker-controlled text.
|
||||
authRefreshLogger().Warn("auth.runtime.refresh.failed",
|
||||
"product", invocation.CanonicalProduct,
|
||||
"tool", invocation.Tool,
|
||||
"stage", "force_refresh_rejected_token",
|
||||
"error_type", fmt.Sprintf("%T", err),
|
||||
)
|
||||
logging.AuthDebug("auth.runtime.refresh.failed.detail",
|
||||
"product", invocation.CanonicalProduct,
|
||||
"tool", invocation.Tool,
|
||||
"stage", "force_refresh_rejected_token",
|
||||
"error_type", fmt.Sprintf("%T", err),
|
||||
)
|
||||
combined := &authRefreshFailureError{rejection: cause, refresh: err}
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"automatic access token refresh failed",
|
||||
apperrors.WithOperation("auth/token/refresh"),
|
||||
apperrors.WithReason("auth_refresh_failed"),
|
||||
apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"),
|
||||
apperrors.WithCause(combined),
|
||||
), true
|
||||
}
|
||||
|
||||
logging.AuthDebug("auth.runtime.refresh.succeeded",
|
||||
"product", invocation.CanonicalProduct,
|
||||
"tool", invocation.Tool,
|
||||
)
|
||||
result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation)
|
||||
return result, err, true
|
||||
}
|
||||
|
||||
// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and
|
||||
// generic CategoryAuth values. OnAuthError may request a refresh only for an
|
||||
// exact transport-level unauthorized signal.
|
||||
func isRefreshableTransportAuthError(err error) bool {
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
|
||||
return false
|
||||
}
|
||||
return typed.Reason == "http_401" || typed.RPCCode == 401
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func installAuthRefreshRunnerSeams(t *testing.T) {
|
||||
t.Helper()
|
||||
previousHooks := edition.Get()
|
||||
previousCall := runnerCallTool
|
||||
previousPreflight := runnerPreflightDocDownload
|
||||
previousRefresh := runnerForceRefreshRejectedAccessToken
|
||||
previousRetry := runnerExecuteAuthRetry
|
||||
previousCapture := runnerCaptureRuntimeFailure
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
previousPlugins := pluginAuthRegistry
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
pluginAuthMu.Unlock()
|
||||
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return nil
|
||||
}
|
||||
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
runtimeTokenManager.Invalidate()
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
t.Setenv("DWS_DEBUG_AUTH", "0")
|
||||
|
||||
t.Cleanup(func() {
|
||||
edition.Override(previousHooks)
|
||||
runnerCallTool = previousCall
|
||||
runnerPreflightDocDownload = previousPreflight
|
||||
runnerForceRefreshRejectedAccessToken = previousRefresh
|
||||
runnerExecuteAuthRetry = previousRetry
|
||||
runnerCaptureRuntimeFailure = previousCapture
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
runtimeTokenManager.Invalidate()
|
||||
pluginAuthMu.Lock()
|
||||
pluginAuthRegistry = previousPlugins
|
||||
pluginAuthMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner {
|
||||
return &runtimeRunner{
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: flags,
|
||||
auditSink: audit.NopSink{},
|
||||
}
|
||||
}
|
||||
|
||||
func authRefreshTestInvocation() executor.Invocation {
|
||||
return executor.Invocation{
|
||||
CanonicalProduct: "auth-retry-test-product",
|
||||
Tool: "test_tool",
|
||||
Params: map[string]any{"value": "safe"},
|
||||
}
|
||||
}
|
||||
|
||||
func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks {
|
||||
return &edition.Hooks{
|
||||
ConfigDir: func() string { return configDir },
|
||||
TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||
return *token, nil
|
||||
},
|
||||
ClassifyToolResult: classify,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
||||
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
||||
if expired, _ := content["expired"].(bool); expired {
|
||||
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
var callTokens []string
|
||||
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
callTokens = append(callTokens, client.AuthToken)
|
||||
if len(callTokens) == 1 {
|
||||
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||
}
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) {
|
||||
refreshCalls++
|
||||
if gotDir != configDir || rejected != "old-access" {
|
||||
t.Fatalf("refresh input = dir %q token %q", gotDir, rejected)
|
||||
}
|
||||
token = "new-access"
|
||||
return token, nil
|
||||
}
|
||||
|
||||
result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" {
|
||||
t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens)
|
||||
}
|
||||
content, _ := result.Response["content"].(map[string]any)
|
||||
if content["value"] != "ok" || content["success"] != true {
|
||||
t.Fatalf("result content = %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
t.Setenv("DWS_DEBUG_AUTH", "1")
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
||||
edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error {
|
||||
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||
}))
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||
}
|
||||
refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`)
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "", refreshErr
|
||||
}
|
||||
|
||||
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) })
|
||||
|
||||
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||
if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("error = %v, want rejection and refresh causes", err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" {
|
||||
t.Fatalf("refresh envelope = %#v", typed)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil {
|
||||
t.Fatal(printErr)
|
||||
}
|
||||
for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} {
|
||||
if !strings.Contains(rendered.String(), want) {
|
||||
t.Fatalf("structured stderr missing %s: %s", want, rendered.String())
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} {
|
||||
if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) {
|
||||
t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String())
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} {
|
||||
if !strings.Contains(logs.String(), want) {
|
||||
t.Fatalf("safe refresh log missing %q: %s", want, logs.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
firstCause := errors.New("first rejection")
|
||||
secondCause := errors.New("second rejection")
|
||||
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
||||
attempt, _ := content["attempt"].(int)
|
||||
if attempt == 1 {
|
||||
return &authretry.AuthRefreshRequired{Cause: firstCause}
|
||||
}
|
||||
return &authretry.AuthRefreshRequired{Cause: secondCause}
|
||||
}))
|
||||
calls := 0
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
calls++
|
||||
return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
refreshCalls++
|
||||
token = "new-access"
|
||||
return token, nil
|
||||
}
|
||||
|
||||
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||
if !errors.Is(err, secondCause) || errors.Is(err, firstCause) {
|
||||
t.Fatalf("error = %v, want only second rejection cause", err)
|
||||
}
|
||||
if calls != 2 || refreshCalls != 1 {
|
||||
t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) {
|
||||
t.Run("http 401 marker retries once", func(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
rejection := errors.New("transport rejected token")
|
||||
hookCalls := 0
|
||||
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
||||
hooks.OnAuthError = func(string, error) error {
|
||||
hookCalls++
|
||||
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||
}
|
||||
edition.Override(hooks)
|
||||
calls := 0
|
||||
var callTokens []string
|
||||
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
calls++
|
||||
callTokens = append(callTokens, client.AuthToken)
|
||||
if calls == 1 {
|
||||
return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401"))
|
||||
}
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
refreshCalls++
|
||||
token = "new-access"
|
||||
return token, nil
|
||||
}
|
||||
|
||||
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" {
|
||||
t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))},
|
||||
{name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))},
|
||||
} {
|
||||
t.Run(tc.name+" does not enter hook", func(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
hookCalls := 0
|
||||
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
||||
hooks.OnAuthError = func(string, error) error {
|
||||
hookCalls++
|
||||
return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")}
|
||||
}
|
||||
edition.Override(hooks)
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, tc.err
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
refreshCalls++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||
if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 {
|
||||
t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
rejection := errors.New("explicit token rejected")
|
||||
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
|
||||
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||
}})
|
||||
calls := 0
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
calls++
|
||||
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
refreshCalls++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||
if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 {
|
||||
t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) {
|
||||
installAuthRefreshRunnerSeams(t)
|
||||
configDir := t.TempDir()
|
||||
token := "old-access"
|
||||
rejection := errors.New("preflight token rejected")
|
||||
edition.Override(authRefreshTokenHooks(configDir, &token, nil))
|
||||
preflightCalls := 0
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
preflightCalls++
|
||||
if preflightCalls == 1 {
|
||||
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
toolCalls := 0
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
toolCalls++
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||
}
|
||||
refreshCalls := 0
|
||||
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||
refreshCalls++
|
||||
token = "new-access"
|
||||
return token, nil
|
||||
}
|
||||
|
||||
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 {
|
||||
t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -554,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())}
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
@@ -1475,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{}, "", "")
|
||||
@@ -1617,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},
|
||||
} {
|
||||
@@ -1631,7 +1628,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
|
||||
}
|
||||
})
|
||||
}
|
||||
if createCount != 1 {
|
||||
if createCount != 2 {
|
||||
t.Fatalf("create count = %d", createCount)
|
||||
}
|
||||
|
||||
@@ -1653,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp", UserID: "user", ClientID: "client",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
|
||||
missing := t.TempDir()
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("missing access token = %q, %v", got, err)
|
||||
}
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
|
||||
|
||||
@@ -56,6 +56,7 @@ var (
|
||||
eventNewEventSource = newEventSource
|
||||
eventNewDingtalkSource = source.New
|
||||
eventResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||
eventForceRefreshRejected = forceRefreshRejectedAccessToken
|
||||
eventBusRun = bus.Run
|
||||
eventReadyFDFromEnv = busctl.ReadyFDFromEnv
|
||||
eventResolvePersonal = resolvePersonalEventIdentity
|
||||
@@ -174,6 +175,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 +314,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", "",
|
||||
@@ -410,7 +414,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
|
||||
return "portal-ticket-normal:" + sourceID
|
||||
}
|
||||
|
||||
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
if !streamOpts.enabled() {
|
||||
return eventNewDingtalkSource(source.Config{
|
||||
ClientID: clientID,
|
||||
@@ -418,14 +422,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
||||
})
|
||||
}
|
||||
|
||||
token, err := eventResolveAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, errors.New("event stream ticket: empty user token")
|
||||
}
|
||||
|
||||
portalClientID := clientID
|
||||
portalClientSecret := clientSecret
|
||||
if streamOpts.usesPortalNormalMode() {
|
||||
@@ -437,8 +433,13 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
||||
ClientID: portalClientID,
|
||||
ClientSecret: portalClientSecret,
|
||||
PortalTicket: &source.PortalTicketConfig{
|
||||
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
|
||||
AccessToken: token,
|
||||
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
|
||||
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||
return eventResolveAccessToken(ctx, configDir, "")
|
||||
},
|
||||
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return eventForceRefreshRejected(ctx, configDir, rejectedToken)
|
||||
},
|
||||
SourceID: eventStreamSourceID(streamOpts.SourceID),
|
||||
Mode: streamOpts.Mode,
|
||||
ClientID: portalClientID,
|
||||
|
||||
@@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
stream := eventStreamTicketOptions{Mode: "custom"}
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
|
||||
t.Fatalf("stream token error = %v", err)
|
||||
var captured source.Config
|
||||
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
|
||||
captured = cfg
|
||||
return &source.DingtalkSource{}, nil
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
|
||||
t.Fatal("empty stream token succeeded")
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil {
|
||||
t.Fatalf("stream source construction = %v", err)
|
||||
}
|
||||
if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) {
|
||||
t.Fatalf("stream token provider error = %v", err)
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||
for _, mode := range []string{"custom", "normal"} {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket
|
||||
// source receives a ForceRefreshToken callback that forwards the actual
|
||||
// rejected token into the app-level compare-and-refresh chain.
|
||||
func TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) {
|
||||
oldNew, oldRefresh := eventNewDingtalkSource, eventForceRefreshRejected
|
||||
t.Cleanup(func() { eventNewDingtalkSource, eventForceRefreshRejected = oldNew, oldRefresh })
|
||||
|
||||
var captured source.Config
|
||||
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
|
||||
captured = cfg
|
||||
return &source.DingtalkSource{}, nil
|
||||
}
|
||||
var gotDir, gotRejected string
|
||||
eventForceRefreshRejected = func(_ context.Context, configDir, rejectedToken string) (string, error) {
|
||||
gotDir, gotRejected = configDir, rejectedToken
|
||||
return "fresh", nil
|
||||
}
|
||||
if _, err := newEventSource(context.Background(), "config-dir", "client", "secret", eventStreamTicketOptions{Mode: "custom"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if captured.PortalTicket == nil || captured.PortalTicket.ForceRefreshToken == nil {
|
||||
t.Fatal("ForceRefreshToken not wired into portal ticket config")
|
||||
}
|
||||
tok, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "rejected-token")
|
||||
if err != nil || tok != "fresh" {
|
||||
t.Fatalf("force refresh = %q, %v", tok, err)
|
||||
}
|
||||
if gotDir != "config-dir" || gotRejected != "rejected-token" {
|
||||
t.Fatalf("wiring passed dir %q rejected %q", gotDir, gotRejected)
|
||||
}
|
||||
|
||||
fail := errors.New("refresh failed")
|
||||
eventForceRefreshRejected = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
if _, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "x"); !errors.Is(err, fail) {
|
||||
t.Fatalf("refresh error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -129,6 +131,7 @@ var (
|
||||
personalFindProcess = os.FindProcess
|
||||
personalSignalProcess = (*os.Process).Signal
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
|
||||
personalLoadTokenData = authpkg.LoadTokenData
|
||||
personalClientID = authpkg.ClientID
|
||||
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
|
||||
@@ -226,8 +229,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 +249,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,
|
||||
@@ -250,7 +260,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
}
|
||||
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
@@ -272,8 +282,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 +294,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 +366,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 +388,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 +430,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
|
||||
@@ -466,7 +499,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
@@ -581,7 +614,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
@@ -691,7 +724,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
if err != nil {
|
||||
return personal.Identity{}, err
|
||||
}
|
||||
tokenData, _ := personalLoadTokenData(configDir)
|
||||
tokenData, err := personalLoadTokenData(configDir)
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
|
||||
}
|
||||
var corpID, userID, clientID, refreshToken string
|
||||
if tokenData != nil {
|
||||
corpID = tokenData.CorpID
|
||||
@@ -737,6 +773,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
@@ -785,7 +830,12 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessToken: opts.Identity.AccessToken,
|
||||
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
|
||||
},
|
||||
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
|
||||
},
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
|
||||
@@ -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", "报警"},
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the
|
||||
// personal stream source receives a ForceRefreshToken callback that forwards
|
||||
// the rejected token into the app-level compare-and-refresh chain.
|
||||
func TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) {
|
||||
oldAux := personalResolveAuxiliaryAccessToken
|
||||
oldRefresh := personalForceRefreshRejectedToken
|
||||
t.Cleanup(func() {
|
||||
personalResolveAuxiliaryAccessToken = oldAux
|
||||
personalForceRefreshRejectedToken = oldRefresh
|
||||
})
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "old-token", nil
|
||||
}
|
||||
refreshErr := errors.New("refresh rejected")
|
||||
var gotDir, gotRejected string
|
||||
personalForceRefreshRejectedToken = func(_ context.Context, configDir, rejectedToken string) (string, error) {
|
||||
gotDir, gotRejected = configDir, rejectedToken
|
||||
return "", refreshErr
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
|
||||
ConfigDir: "config-dir",
|
||||
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
|
||||
TicketURL: srv.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The 401 ticket response routes the rejected token through the wired
|
||||
// ForceRefreshToken; the unknown refresh failure stays fatal.
|
||||
if err := src.Start(context.Background(), func(*dwsevent.RawEvent) {}); !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("Start() error = %v, want wrapped refresh error", err)
|
||||
}
|
||||
if gotDir != "config-dir" || gotRejected != "old-token" {
|
||||
t.Fatalf("refresh wiring got dir %q rejected %q", gotDir, gotRejected)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,9 +27,13 @@ type accessTokenGetter interface {
|
||||
GetAccessToken(context.Context) (string, error)
|
||||
}
|
||||
|
||||
type rejectedAccessTokenRefresher interface {
|
||||
ForceRefreshRejectedToken(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
markAccessTokenStale = authpkg.MarkAccessTokenStale
|
||||
newRefreshProvider = func(configDir string) accessTokenGetter {
|
||||
loadRefreshTokenData = authpkg.LoadTokenData
|
||||
newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
@@ -42,26 +46,33 @@ var (
|
||||
// server-side rejection (HTTP 401 or business code such as
|
||||
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
||||
//
|
||||
// Steps:
|
||||
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
|
||||
// OAuthProvider.GetAccessToken's fast-path will miss.
|
||||
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
|
||||
// existing dual-layer lock (process + file) to serialize concurrent
|
||||
// refresh attempts across goroutines and processes.
|
||||
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
|
||||
// next resolveAuthToken call re-reads from disk.
|
||||
//
|
||||
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
|
||||
// is the only entry point that orchestrates "force refresh" semantics.
|
||||
// It snapshots the current access token, then delegates to the OAuth
|
||||
// provider's dual-locked compare-and-refresh operation. If another caller has
|
||||
// already rotated the token, that newer token is reused without another
|
||||
// refresh request.
|
||||
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if err := markAccessTokenStale(configDir); err != nil {
|
||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||
data, err := loadRefreshTokenData(configDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.AccessToken) == "" {
|
||||
return "", fmt.Errorf("stored access token is empty")
|
||||
}
|
||||
return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken)
|
||||
}
|
||||
|
||||
func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if strings.TrimSpace(rejectedAccessToken) == "" {
|
||||
return "", fmt.Errorf("rejected access token is empty")
|
||||
}
|
||||
provider := newRefreshProvider(configDir)
|
||||
tok, err := provider.GetAccessToken(ctx)
|
||||
tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser
|
||||
var (
|
||||
patAuthorizationTimeout = PatAuthRetryTimeout
|
||||
patAuthorizationPollInterval = PatAuthPollInterval
|
||||
patLoadTokenData = authpkg.LoadTokenData
|
||||
patResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||
patWaitForAuthorization = WaitForPatAuthorization
|
||||
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||
patSaveAppConfig = authpkg.SaveAppConfig
|
||||
@@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string {
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) {
|
||||
timeout := patAuthorizationTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(patAuthorizationPollInterval)
|
||||
@@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||||
return false
|
||||
return false, ctx.Err()
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
||||
return false
|
||||
return false, nil
|
||||
|
||||
case <-pollTicker.C:
|
||||
pollCount++
|
||||
elapsed := time.Since(start).Truncate(time.Second)
|
||||
remaining := time.Until(deadline).Truncate(time.Second)
|
||||
|
||||
// Check if token is now valid
|
||||
tokenData, err := patLoadTokenData(configDir)
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
// Check the same resolver used by every outbound bearer request.
|
||||
if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true, nil
|
||||
} else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return false, fmt.Errorf("check authorization token: %w", err)
|
||||
}
|
||||
|
||||
// Show polling status
|
||||
@@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
// Wait for user to complete authorization
|
||||
authorized := patWaitForAuthorization(ctx, configDir, output)
|
||||
authorized, waitErr := patWaitForAuthorization(ctx, configDir, output)
|
||||
if waitErr != nil {
|
||||
return executor.Result{}, waitErr
|
||||
}
|
||||
if !authorized {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"等待用户授权超时",
|
||||
@@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
// Load user access token for the poll request header.
|
||||
var accessToken string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
|
||||
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
||||
noRedirectClient := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
@@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||
continue
|
||||
}
|
||||
accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "")
|
||||
if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) {
|
||||
return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr)
|
||||
}
|
||||
if accessToken != "" {
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
}
|
||||
|
||||
@@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
||||
|
||||
oldTimeout := patAuthorizationTimeout
|
||||
oldInterval := patAuthorizationPollInterval
|
||||
oldLoad := patLoadTokenData
|
||||
oldResolve := patResolveAccessToken
|
||||
t.Cleanup(func() {
|
||||
patAuthorizationTimeout = oldTimeout
|
||||
patAuthorizationPollInterval = oldInterval
|
||||
patLoadTokenData = oldLoad
|
||||
patResolveAccessToken = oldResolve
|
||||
})
|
||||
patAuthorizationTimeout = 50 * time.Millisecond
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "token", nil
|
||||
}
|
||||
out.Reset()
|
||||
if !WaitForPatAuthorization(context.Background(), "", &out) {
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok {
|
||||
t.Fatal("valid token did not authorize")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(ctx, "", &out) {
|
||||
t.Fatal("cancelled authorization succeeded")
|
||||
if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = time.Millisecond
|
||||
patAuthorizationPollInterval = time.Hour
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(context.Background(), "", &out) {
|
||||
t.Fatal("timed out authorization succeeded")
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling output = %q", out.String())
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil }
|
||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
|
||||
t.Fatal("PAT retry timeout succeeded")
|
||||
}
|
||||
wantErr := errors.New("runner failed")
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil }
|
||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("authorized retry = %v", err)
|
||||
}
|
||||
@@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string {
|
||||
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
|
||||
oldDo := patPollHTTPDo
|
||||
oldRequest := patPollNewRequest
|
||||
oldLoad := patLoadTokenData
|
||||
oldResolve := patResolveAccessToken
|
||||
oldBrowser := patBrowserOpenCommand
|
||||
t.Cleanup(func() {
|
||||
patPollHTTPDo = oldDo
|
||||
patPollNewRequest = oldRequest
|
||||
patLoadTokenData = oldLoad
|
||||
patResolveAccessToken = oldResolve
|
||||
patBrowserOpenCommand = oldBrowser
|
||||
})
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||
cancelled, cancelNow := context.WithCancel(context.Background())
|
||||
cancelNow()
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
|
||||
|
||||
@@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders())
|
||||
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||
if err != nil {
|
||||
return nil, tokenResolutionError(err)
|
||||
}
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+64
-54
@@ -235,7 +235,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
if prefetchToken {
|
||||
go runnerGetCachedRuntimeToken(ctx)
|
||||
go func() {
|
||||
_, _ = runnerGetCachedRuntimeToken(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
@@ -534,8 +536,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
authToken := ""
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else {
|
||||
authToken = r.resolveAuthToken(ctx)
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = r.resolveAuthToken(ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
@@ -617,6 +623,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled {
|
||||
if retryErr != nil {
|
||||
runnerCaptureRuntimeFailure(invocation, err, retryErr)
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
@@ -625,9 +637,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if isRefreshableTransportAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled {
|
||||
if retryErr != nil {
|
||||
runnerCaptureRuntimeFailure(invocation, err, retryErr)
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
|
||||
return executor.Result{}, overrideErr
|
||||
}
|
||||
@@ -652,6 +670,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled {
|
||||
if retryErr != nil {
|
||||
runnerCaptureRuntimeFailure(invocation, editionErr, retryErr)
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
return executor.Result{}, editionErr
|
||||
}
|
||||
}
|
||||
@@ -672,6 +696,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled {
|
||||
if retryErr != nil {
|
||||
runnerCaptureRuntimeFailure(invocation, hookErr, retryErr)
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
return executor.Result{}, hookErr
|
||||
}
|
||||
@@ -796,67 +826,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return token
|
||||
}
|
||||
if tp := edition.Get().TokenProvider; tp != nil {
|
||||
token, _ := tp(ctx, func() (string, error) {
|
||||
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
|
||||
})
|
||||
return token
|
||||
}
|
||||
return getCachedRuntimeToken(ctx)
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return token
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Use cached token to avoid repeated Keychain access (~70ms per call)
|
||||
return getCachedRuntimeToken(ctx)
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeTokenMu sync.Mutex
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
)
|
||||
|
||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
if cacheKey == "" {
|
||||
cacheKey = "__default__"
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
|
||||
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||
// cache itself lives exclusively in TokenManager.
|
||||
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
return resolveRuntimeAuthToken(ctx, "")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
func tokenResolutionError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if token == "" {
|
||||
return ""
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
cachedRuntimeTokens[cacheKey] = token
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return apperrors.NewAuth(
|
||||
"未登录,请先执行 dws auth login",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
// Keychain, parse, permission, lock, and refresh failures are real local or
|
||||
// network errors. Preserve their cause instead of disguising them as logout.
|
||||
return fmt.Errorf("resolve access token: %w", err)
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
@@ -871,9 +883,7 @@ func generateExecutionID() string {
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
defer cachedRuntimeTokenMu.Unlock()
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
runtimeTokenManager.Invalidate()
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
|
||||
@@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
|
||||
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
|
||||
prefetched := make(chan struct{}, 1)
|
||||
runnerGetCachedRuntimeToken = func(context.Context) string {
|
||||
runnerGetCachedRuntimeToken = func(context.Context) (string, error) {
|
||||
prefetched <- struct{}{}
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
r := &runtimeRunner{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||
@@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
authErr := apperrors.NewAuth("expired")
|
||||
authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401"))
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, authErr
|
||||
}
|
||||
@@ -329,19 +329,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
}
|
||||
|
||||
r.globalFlags.Token = " explicit "
|
||||
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
|
||||
t.Fatalf("explicit auth token = %q", got)
|
||||
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
|
||||
t.Fatalf("explicit auth token = %q, %v", got, err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||
_, _ = fallback()
|
||||
return "provided", nil
|
||||
}})
|
||||
r.globalFlags.Token = ""
|
||||
if got := r.resolveAuthToken(context.Background()); got != "provided" {
|
||||
t.Fatalf("provided auth token = %q", got)
|
||||
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" {
|
||||
t.Fatalf("provided auth token = %q, %v", got, err)
|
||||
}
|
||||
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q", got)
|
||||
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
|
||||
@@ -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
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -36,25 +37,25 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
skillLoadAccessToken = loadSkillAccessToken
|
||||
skillDownloadToTmp = downloadSkillToTmpDir
|
||||
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||||
skillNewRequest = http.NewRequestWithContext
|
||||
skillLoadTokenData = authpkg.LoadTokenData
|
||||
skillResolveTargetPath = resolveSkillTargetPath
|
||||
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||||
skillDownloadFile = downloadSkillFile
|
||||
skillExtractZip = extractSkillZip
|
||||
skillUserHomeDir = os.UserHomeDir
|
||||
skillMkdirTemp = os.MkdirTemp
|
||||
skillCreate = os.Create
|
||||
skillCreateTemp = os.CreateTemp
|
||||
skillRemoveAll = os.RemoveAll
|
||||
skillRemove = os.Remove
|
||||
skillMkdirAll = os.MkdirAll
|
||||
skillOpenFile = os.OpenFile
|
||||
skillCopy = io.Copy
|
||||
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||||
skillLoadAccessToken = loadSkillAccessToken
|
||||
skillDownloadToTmp = downloadSkillToTmpDir
|
||||
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||||
skillNewRequest = http.NewRequestWithContext
|
||||
skillResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||
skillResolveTargetPath = resolveSkillTargetPath
|
||||
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||||
skillDownloadFile = downloadSkillFile
|
||||
skillExtractZip = extractSkillZip
|
||||
skillUserHomeDir = os.UserHomeDir
|
||||
skillMkdirTemp = os.MkdirTemp
|
||||
skillCreate = os.Create
|
||||
skillCreateTemp = os.CreateTemp
|
||||
skillRemoveAll = os.RemoveAll
|
||||
skillRemove = os.Remove
|
||||
skillMkdirAll = os.MkdirAll
|
||||
skillOpenFile = os.OpenFile
|
||||
skillCopy = io.Copy
|
||||
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command {
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
if source == "" {
|
||||
source, _ = cmd.Flags().GetString("scopes")
|
||||
}
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||
}
|
||||
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSkillAccessToken() (string, error) {
|
||||
func loadSkillAccessToken(ctx context.Context) (string, error) {
|
||||
configDir := defaultConfigDir()
|
||||
tokenData, err := skillLoadTokenData(configDir)
|
||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||
token, err := skillResolveAccessToken(ctx, configDir, "")
|
||||
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
return "", skillAuthError()
|
||||
}
|
||||
return tokenData.AccessToken, nil
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve skill access token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func skillAuthError() error {
|
||||
|
||||
@@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
cmd := skillCoverageCommand()
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill get auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find request failure should propagate")
|
||||
@@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
t.Fatalf("skill get download error = %v", err)
|
||||
}
|
||||
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill find auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find network failure should propagate")
|
||||
@@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
t.Fatal("invalid skill target should fail")
|
||||
}
|
||||
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill add auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill info error = %v", err)
|
||||
@@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
|
||||
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldHTTP := skillHTTPDo
|
||||
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
|
||||
oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken
|
||||
oldHome := skillUserHomeDir
|
||||
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
|
||||
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
|
||||
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
|
||||
t.Cleanup(func() {
|
||||
skillHTTPDo = oldHTTP
|
||||
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
|
||||
skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken
|
||||
skillUserHomeDir = oldHome
|
||||
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
|
||||
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
|
||||
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||
if _, err := loadSkillAccessToken(); err == nil {
|
||||
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
if _, err := loadSkillAccessToken(context.Background()); err == nil {
|
||||
t.Fatal("invalid skill access token succeeded")
|
||||
}
|
||||
skillLoadTokenData = oldLoadToken
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("skill token cancellation = %v", err)
|
||||
}
|
||||
skillResolveAccessToken = oldResolveToken
|
||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatal("download-info request failure should propagate")
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
originalLoadToken := skillLoadTokenData
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
|
||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
||||
originalResolveToken := skillResolveAccessToken
|
||||
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
|
||||
|
||||
// Ensure the config directory exists but has no token
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
@@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
|
||||
configDir := filepath.Join(t.TempDir(), "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
originalLoadToken := skillLoadTokenData
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "test-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
}, nil
|
||||
originalResolveToken := skillResolveAccessToken
|
||||
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "test-token", nil
|
||||
}
|
||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
||||
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
|
||||
|
||||
var gotSources []string
|
||||
var gotScopes []string
|
||||
|
||||
@@ -52,6 +52,10 @@ func TestMain(m *testing.M) {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.TestNamespaceEnv + ": " + 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())
|
||||
@@ -79,6 +83,12 @@ func TestMain(m *testing.M) {
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err)
|
||||
if code == 0 {
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
|
||||
if code == 0 {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) {
|
||||
oldLoad := oauthLoadToken
|
||||
want := errors.New("keychain permission denied")
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
|
||||
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v, want cause %v", err, want)
|
||||
}
|
||||
if errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("load failure was misclassified as missing credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) {
|
||||
oldLoad := oauthLoadToken
|
||||
want := errors.New("keychain permission denied")
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
|
||||
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v, want cause %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) {
|
||||
oldLoad := oauthLoadToken
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
oauthLoadToken = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||
|
||||
snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("snapshot = %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, present, err := ReadTokenMarkerRevision(configDir)
|
||||
if err != nil || !present || first == "" {
|
||||
t.Fatalf("first marker = %q, %v, %v", first, present, err)
|
||||
}
|
||||
if err := WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, present, err := ReadTokenMarkerRevision(configDir)
|
||||
if err != nil || !present || second == "" || second == first {
|
||||
t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first)
|
||||
}
|
||||
}
|
||||
@@ -722,6 +722,6 @@ func isInvalidGrantError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
msg := strings.ToLower(err.Error() + " " + httpStatusResponseBody(err))
|
||||
return strings.Contains(msg, "invalid_grant") || (strings.Contains(msg, "code") && strings.Contains(msg, "expired"))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,50 @@
|
||||
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const rejectedTokenRefreshFailureCooldown = 3 * time.Second
|
||||
|
||||
type rejectedTokenRefreshKey struct {
|
||||
configDir string
|
||||
profile string
|
||||
tokenDigest [sha256.Size]byte
|
||||
}
|
||||
|
||||
type rejectedTokenRefreshCall struct {
|
||||
done chan struct{}
|
||||
participants int
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
type rejectedTokenRefreshFailure struct {
|
||||
at time.Time
|
||||
err error
|
||||
}
|
||||
|
||||
var rejectedTokenRefreshCoordinator = struct {
|
||||
sync.Mutex
|
||||
inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall
|
||||
failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure
|
||||
now func() time.Time
|
||||
}{
|
||||
inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall),
|
||||
failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure),
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
||||
// instant (preserving access_token and refresh_token), and writes it back. The
|
||||
@@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error {
|
||||
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
return SaveTokenData(configDir, data)
|
||||
}
|
||||
|
||||
// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is
|
||||
// still the credential stored for the active profile. The compare and refresh
|
||||
// run under the same process + file lock used by ordinary expiry refresh, so a
|
||||
// late rejection cannot invalidate or refresh over a token another caller has
|
||||
// already rotated.
|
||||
//
|
||||
// When the stored token no longer matches, the newer token is returned without
|
||||
// calling the refresh endpoint. Refresh failures leave the stored credential in
|
||||
// place; login/logout remain the only owners of credential deletion.
|
||||
func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) {
|
||||
if p == nil || strings.TrimSpace(p.configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
rejectedAccessToken = strings.TrimSpace(rejectedAccessToken)
|
||||
if rejectedAccessToken == "" {
|
||||
return "", fmt.Errorf("rejected access token is empty")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
profile := strings.TrimSpace(RuntimeProfile())
|
||||
key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken)
|
||||
call, leader := beginRejectedTokenRefresh(key)
|
||||
if !leader {
|
||||
select {
|
||||
case <-call.done:
|
||||
return call.token, call.err
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key)
|
||||
finishRejectedTokenRefresh(key, call, token, err, recordFailure)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) forceRefreshRejectedTokenOnce(
|
||||
ctx context.Context,
|
||||
profile string,
|
||||
rejectedAccessToken string,
|
||||
key rejectedTokenRefreshKey,
|
||||
) (string, error, bool) {
|
||||
lock, err := oauthAcquireLock(ctx, p.configDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("acquiring dual lock: %w", err), false
|
||||
}
|
||||
defer lock.Release()
|
||||
|
||||
data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reload rejected token: %w", err), false
|
||||
}
|
||||
current := strings.TrimSpace(data.AccessToken)
|
||||
if current == "" {
|
||||
clearRejectedTokenRefreshFailure(key)
|
||||
return "", fmt.Errorf("stored access token is empty"), false
|
||||
}
|
||||
if current != rejectedAccessToken {
|
||||
clearRejectedTokenRefreshFailure(key)
|
||||
return current, nil, false
|
||||
}
|
||||
if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil {
|
||||
return "", cachedErr, false
|
||||
}
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return "", fmt.Errorf("refresh_token 已过期"), true
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
|
||||
return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true
|
||||
}
|
||||
|
||||
refreshed, err := oauthRefreshToken(p, ctx, data)
|
||||
if err != nil {
|
||||
return "", err, true
|
||||
}
|
||||
if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" {
|
||||
return "", fmt.Errorf("force refresh returned empty access token"), true
|
||||
}
|
||||
return strings.TrimSpace(refreshed.AccessToken), nil, false
|
||||
}
|
||||
|
||||
func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey {
|
||||
canonicalDir := filepath.Clean(configDir)
|
||||
if absolute, err := filepath.Abs(configDir); err == nil {
|
||||
canonicalDir = filepath.Clean(absolute)
|
||||
}
|
||||
return rejectedTokenRefreshKey{
|
||||
configDir: canonicalDir,
|
||||
profile: strings.TrimSpace(profile),
|
||||
tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))),
|
||||
}
|
||||
}
|
||||
|
||||
func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||
|
||||
if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil {
|
||||
call.participants++
|
||||
return call, false
|
||||
}
|
||||
call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1}
|
||||
rejectedTokenRefreshCoordinator.inFlight[key] = call
|
||||
return call, true
|
||||
}
|
||||
|
||||
func finishRejectedTokenRefresh(
|
||||
key rejectedTokenRefreshKey,
|
||||
call *rejectedTokenRefreshCall,
|
||||
token string,
|
||||
err error,
|
||||
recordFailure bool,
|
||||
) {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||
|
||||
call.token = token
|
||||
call.err = err
|
||||
delete(rejectedTokenRefreshCoordinator.inFlight, key)
|
||||
if err == nil {
|
||||
delete(rejectedTokenRefreshCoordinator.failures, key)
|
||||
} else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
now := time.Now()
|
||||
if rejectedTokenRefreshCoordinator.now != nil {
|
||||
now = rejectedTokenRefreshCoordinator.now()
|
||||
}
|
||||
rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err}
|
||||
}
|
||||
close(call.done)
|
||||
}
|
||||
|
||||
func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if rejectedTokenRefreshCoordinator.now != nil {
|
||||
now = rejectedTokenRefreshCoordinator.now()
|
||||
}
|
||||
for failureKey, failure := range rejectedTokenRefreshCoordinator.failures {
|
||||
age := now.Sub(failure.at)
|
||||
if age < 0 || age >= rejectedTokenRefreshFailureCooldown {
|
||||
delete(rejectedTokenRefreshCoordinator.failures, failureKey)
|
||||
}
|
||||
}
|
||||
if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok {
|
||||
return failure.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
delete(rejectedTokenRefreshCoordinator.failures, key)
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
}
|
||||
|
||||
// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a
|
||||
// second, non-reentrant auth lock. Opaque edition storage hooks (for example
|
||||
// Wukong's encrypted .data file) are read inside the caller's dual lock so the
|
||||
// compare-and-refresh decision covers both Core and embedded storage.
|
||||
func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) {
|
||||
hooks := edition.Get()
|
||||
if hooks.LoadToken == nil {
|
||||
data, err := oauthLoadTokenLocked(configDir, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("stored token data is empty")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
blob, err := hooks.LoadToken(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data TokenData
|
||||
if err := json.Unmarshal(blob, &data); err != nil {
|
||||
return nil, fmt.Errorf("parsing token data from hook: %w", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
@@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) {
|
||||
}
|
||||
return token, "file", nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", "", fmt.Errorf("load legacy token: %w", err)
|
||||
}
|
||||
return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound)
|
||||
}
|
||||
|
||||
func (m *Manager) GetMCPURL() (string, error) {
|
||||
|
||||
@@ -237,12 +237,21 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200))
|
||||
// Preserve structured HTTP status semantics even when the response
|
||||
// body is truncated. The body is diagnostic-only here, so read it
|
||||
// best-effort and classify retryability from the status code.
|
||||
if readErr != nil {
|
||||
data = nil
|
||||
}
|
||||
return nil, &HTTPStatusError{
|
||||
StatusCode: resp.StatusCode,
|
||||
responseBody: truncateBody(data, 200),
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", readErr)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type postJSONRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f postJSONRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type oauthBrokenBody struct{}
|
||||
|
||||
func (oauthBrokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (oauthBrokenBody) Close() error { return nil }
|
||||
|
||||
func TestCrossPlatformCoveragePostJSONTruncatedErrorBodyKeepsHTTPStatus(t *testing.T) {
|
||||
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
|
||||
})}}
|
||||
|
||||
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.StatusCode != status {
|
||||
t.Fatalf("postJSON() error = %v, want HTTPStatusError %d", err, status)
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("HTTP status error should not expose diagnostic body read failure: %v", err)
|
||||
}
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePostJSONOKTruncatedBodyIsTransient(t *testing.T) {
|
||||
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
|
||||
})}}
|
||||
|
||||
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("postJSON() error = %v, want io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -115,6 +116,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
// Smart degradation: try silent refresh before opening browser.
|
||||
if !force {
|
||||
data, err := oauthLoadToken(p.configDir)
|
||||
if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) {
|
||||
if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr)
|
||||
}
|
||||
return nil, fmt.Errorf("load existing access token: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
// Case 1: access_token still valid — no action needed.
|
||||
if data.IsAccessTokenValid() {
|
||||
@@ -295,7 +302,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", exchangeErr.Error())
|
||||
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", html.EscapeString(oauthExchangeDisplayError(exchangeErr)))
|
||||
select {
|
||||
case resultCh <- callbackResult{err: exchangeErr}:
|
||||
default:
|
||||
@@ -636,36 +643,63 @@ continueLogin:
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// GetAccessToken returns a valid access token, auto-refreshing if needed.
|
||||
// Uses a file lock with double-check pattern to prevent concurrent refresh
|
||||
// from multiple CLI processes.
|
||||
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
func oauthExchangeDisplayError(err error) string {
|
||||
var statusErr *HTTPStatusError
|
||||
if errors.As(err, &statusErr) && statusErr != nil {
|
||||
return fmt.Sprintf("HTTP %d: token exchange failed", statusErr.StatusCode)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// GetTokenSnapshot returns a valid token together with its expiry metadata.
|
||||
// Storage and refresh failures retain their original cause; only a confirmed
|
||||
// missing credential is reported as ErrTokenDataNotFound.
|
||||
func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) {
|
||||
data, err := oauthLoadToken(p.configDir)
|
||||
if err != nil {
|
||||
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
|
||||
if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("load access token: %w", err)
|
||||
}
|
||||
|
||||
// Fast path: access_token still valid — no lock needed.
|
||||
if data.IsAccessTokenValid() {
|
||||
return data.AccessToken, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Slow path: token expired — try locked refresh.
|
||||
if data.IsRefreshTokenValid() {
|
||||
refreshed, rErr := p.lockedRefresh(ctx)
|
||||
if rErr == nil {
|
||||
return refreshed.AccessToken, nil
|
||||
return refreshed, nil
|
||||
}
|
||||
// A network, timeout, rate-limit or 5xx failure does not invalidate the
|
||||
// refresh credential. Keep the profile active so a long-running source
|
||||
// can retry after backoff. Terminal and unknown failures remain fatal.
|
||||
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), 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)
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
||||
} else {
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
|
||||
}
|
||||
|
||||
// GetAccessToken returns a valid access token, auto-refreshing if needed.
|
||||
// Uses a file lock with double-check pattern to prevent concurrent refresh
|
||||
// from multiple CLI processes.
|
||||
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
data, err := p.GetTokenSnapshot(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(data.AccessToken), nil
|
||||
}
|
||||
|
||||
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
|
||||
@@ -697,7 +731,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 := oauthLoadTokenLocked(p.configDir, RuntimeProfile())
|
||||
data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
type oauthLoginFixture struct {
|
||||
@@ -33,8 +35,79 @@ type oauthLoginFixture struct {
|
||||
exchangeError atomic.Bool
|
||||
}
|
||||
|
||||
type oauthLoginResult struct {
|
||||
token *TokenData
|
||||
err error
|
||||
}
|
||||
|
||||
type oauthHTTPResult struct {
|
||||
status int
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
const oauthTestWaitTimeout = 5 * time.Second
|
||||
|
||||
func isolateOAuthPersistence(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
cleanupKeychain(t)
|
||||
}
|
||||
|
||||
func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
done := make(chan oauthLoginResult, 1)
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
token, err := f.provider.Login(ctx, true)
|
||||
done <- oauthLoginResult{token: token, err: err}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Errorf("OAuth Login goroutine did not stop after cancellation")
|
||||
}
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult {
|
||||
t.Helper()
|
||||
select {
|
||||
case result := <-done:
|
||||
return result
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Fatal("timed out waiting for OAuth Login")
|
||||
return oauthLoginResult{}
|
||||
}
|
||||
}
|
||||
|
||||
func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case result := <-done:
|
||||
t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err)
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Fatalf("timed out waiting for OAuth %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func closeOAuthRelease(ch chan struct{}) {
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
@@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.server.Close)
|
||||
t.Cleanup(func() {
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
})
|
||||
f.configDir = setupMCPConfigDir(t, f.server.URL)
|
||||
|
||||
oldClient := oauthHTTPClient
|
||||
@@ -118,17 +195,25 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
|
||||
|
||||
func httpGetBody(t *testing.T, rawURL string) (int, string) {
|
||||
t.Helper()
|
||||
result := getHTTPBody(rawURL)
|
||||
if result.err != nil {
|
||||
t.Fatalf("GET %s: %v", rawURL, result.err)
|
||||
}
|
||||
return result.status, result.body
|
||||
}
|
||||
|
||||
func getHTTPBody(rawURL string) oauthHTTPResult {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", rawURL, err)
|
||||
return oauthHTTPResult{err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return oauthHTTPResult{err: err}
|
||||
}
|
||||
return resp.StatusCode, string(data)
|
||||
return oauthHTTPResult{status: resp.StatusCode, body: string(data)}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||
@@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||
})
|
||||
|
||||
loginDone := make(chan struct {
|
||||
token *TokenData
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
token, err := f.provider.Login(context.Background(), true)
|
||||
loginDone <- struct {
|
||||
token *TokenData
|
||||
err error
|
||||
}{token, err}
|
||||
}()
|
||||
loginDone := startOAuthLogin(t, context.Background(), f)
|
||||
|
||||
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
|
||||
_, body := httpGetBody(t, f.callbackBase+path)
|
||||
@@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||
t.Fatalf("success page = %q", body)
|
||||
}
|
||||
|
||||
callbackDone := make(chan string, 1)
|
||||
callbackDone := make(chan oauthHTTPResult, 1)
|
||||
go func() {
|
||||
_, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
||||
callbackDone <- callbackBody
|
||||
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good")
|
||||
}()
|
||||
<-f.exchangeEntered
|
||||
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
|
||||
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
|
||||
if !strings.Contains(body, "正在处理授权") {
|
||||
t.Fatalf("concurrent callback = %q", body)
|
||||
}
|
||||
close(f.exchangeRelease)
|
||||
<-f.statusEntered
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
|
||||
|
||||
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
||||
if !strings.Contains(body, "<html") {
|
||||
@@ -204,11 +278,16 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||
t.Fatalf("auth enabled API = %q", body)
|
||||
}
|
||||
|
||||
close(f.statusRelease)
|
||||
if callbackBody := <-callbackDone; !strings.Contains(callbackBody, "<html") {
|
||||
t.Fatalf("callback body = %q", callbackBody)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
select {
|
||||
case callback := <-callbackDone:
|
||||
if callback.err != nil || !strings.Contains(callback.body, "<html") {
|
||||
t.Fatalf("callback body = %q, %v", callback.body, callback.err)
|
||||
}
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Fatal("timed out waiting for OAuth callback")
|
||||
}
|
||||
result := <-loginDone
|
||||
result := awaitOAuthLogin(t, loginDone)
|
||||
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
||||
t.Fatalf("Login = %#v, %v", result.token, result.err)
|
||||
}
|
||||
@@ -228,23 +307,20 @@ func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||
})
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
|
||||
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
|
||||
t.Fatalf("missing callback = %d %q", status, body)
|
||||
}
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("missing callback code did not fail login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
var buf bytes.Buffer
|
||||
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
|
||||
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
|
||||
@@ -311,6 +387,7 @@ func TestCrossPlatformCoverageOAuthLoginTimeoutAndServerError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
dir := t.TempDir()
|
||||
_ = DeleteTokenDataKeychain()
|
||||
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
|
||||
@@ -363,6 +440,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
@@ -425,16 +503,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
||||
for _, tt := range terminal {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
f.provider.NoBrowser = false
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("denied login succeeded")
|
||||
}
|
||||
})
|
||||
@@ -445,15 +519,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
|
||||
})
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("channel-denied login succeeded")
|
||||
}
|
||||
})
|
||||
@@ -463,15 +533,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
||||
})
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("approval timeout login succeeded")
|
||||
}
|
||||
oauthApprovalTimeout = oldApproval
|
||||
@@ -481,16 +547,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
|
||||
})
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("poll-enabled login failed: %v", err)
|
||||
if result := awaitOAuthLogin(t, done); result.err != nil {
|
||||
t.Fatalf("poll-enabled login failed: %v", result.err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
||||
})
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(ctx, true)
|
||||
done <- err
|
||||
}()
|
||||
done := startOAuthLogin(t, ctx, f)
|
||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
||||
cancel()
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("canceled pending login succeeded")
|
||||
}
|
||||
})
|
||||
@@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||
})
|
||||
f.exchangeError.Store(true)
|
||||
close(f.exchangeRelease)
|
||||
close(f.statusRelease)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.provider.Login(context.Background(), true)
|
||||
done <- err
|
||||
}()
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
|
||||
if !strings.Contains(body, "failed") {
|
||||
t.Fatalf("exchange failure page = %q", body)
|
||||
}
|
||||
if err := <-done; err == nil {
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||
t.Fatal("exchange failure login succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
dir := t.TempDir()
|
||||
@@ -640,6 +695,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
oldLoad := oauthLoadToken
|
||||
oldLoadLocked := oauthLoadTokenLocked
|
||||
oldAcquire := oauthAcquireLock
|
||||
@@ -800,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
oauthSuccessPause = 0
|
||||
oauthSleep = func(time.Duration) {}
|
||||
|
||||
type loginResult struct {
|
||||
token *TokenData
|
||||
err error
|
||||
}
|
||||
startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult {
|
||||
finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string {
|
||||
t.Helper()
|
||||
done := make(chan loginResult, 1)
|
||||
bodyCh := make(chan oauthHTTPResult, 1)
|
||||
go func() {
|
||||
token, err := f.provider.Login(ctx, true)
|
||||
done <- loginResult{token: token, err: err}
|
||||
bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code))
|
||||
}()
|
||||
return done
|
||||
}
|
||||
finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string {
|
||||
t.Helper()
|
||||
bodyCh := make(chan string, 1)
|
||||
go func() {
|
||||
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code))
|
||||
bodyCh <- body
|
||||
}()
|
||||
<-f.exchangeEntered
|
||||
close(f.exchangeRelease)
|
||||
return <-bodyCh
|
||||
waitOAuthSignal(t, f.exchangeEntered, done, "token exchange")
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
select {
|
||||
case result := <-bodyCh:
|
||||
if result.err != nil {
|
||||
t.Fatalf("OAuth callback failed: %v", result.err)
|
||||
}
|
||||
return result.body
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Fatal("timed out waiting for OAuth callback")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("switch organization and cached disabled pages", func(t *testing.T) {
|
||||
@@ -835,8 +886,8 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
|
||||
}
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||
done := startLogin(context.Background(), f)
|
||||
if body := finishExchange(t, f, "first"); !strings.Contains(body, "<html") {
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
if body := finishExchange(t, f, done, "first"); !strings.Contains(body, "<html") {
|
||||
t.Fatalf("disabled callback body = %q", body)
|
||||
}
|
||||
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=first"); !strings.Contains(body, "<html") {
|
||||
@@ -848,7 +899,7 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=second"); !strings.Contains(body, "<html") {
|
||||
t.Fatalf("switched callback = %q", body)
|
||||
}
|
||||
result := <-done
|
||||
result := awaitOAuthLogin(t, done)
|
||||
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
||||
t.Fatalf("switched login = %#v %v", result.token, result.err)
|
||||
}
|
||||
@@ -862,15 +913,15 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) { return nil, fail }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||
done := startLogin(ctx, f)
|
||||
finishExchange(t, f, "errors")
|
||||
done := startOAuthLogin(t, ctx, f)
|
||||
finishExchange(t, f, done, "errors")
|
||||
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
|
||||
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
|
||||
t.Fatalf("API error %s = %q", path, body)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if result := <-done; !errors.Is(result.err, context.Canceled) {
|
||||
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
|
||||
t.Fatalf("canceled error login = %v", result.err)
|
||||
}
|
||||
})
|
||||
@@ -887,14 +938,14 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||
var output bytes.Buffer
|
||||
f.provider.Output = &output
|
||||
done := startLogin(ctx, f)
|
||||
finishExchange(t, f, "apply")
|
||||
done := startOAuthLogin(t, ctx, f)
|
||||
finishExchange(t, f, done, "apply")
|
||||
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
|
||||
t.Fatalf("apply response = %q", body)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
if result := <-done; !errors.Is(result.err, context.Canceled) {
|
||||
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
|
||||
t.Fatalf("canceled apply login = %v", result.err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "Waiting for admin approval") && !strings.Contains(output.String(), "等待管理员审批中") {
|
||||
@@ -908,9 +959,9 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
return &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}, nil
|
||||
}
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||
done := startLogin(context.Background(), f)
|
||||
finishExchange(t, f, "enterprise")
|
||||
if result := <-done; result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
finishExchange(t, f, done, "enterprise")
|
||||
if result := awaitOAuthLogin(t, done); result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
|
||||
t.Fatalf("enterprise denial = %v", result.err)
|
||||
}
|
||||
})
|
||||
@@ -933,15 +984,16 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
||||
fail := errors.New("save failure")
|
||||
oauthSaveToken = func(string, *TokenData) error { return fail }
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||
done := startLogin(context.Background(), f)
|
||||
finishExchange(t, f, "save")
|
||||
if result := <-done; !errors.Is(result.err, fail) {
|
||||
done := startOAuthLogin(t, context.Background(), f)
|
||||
finishExchange(t, f, done, "save")
|
||||
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, fail) {
|
||||
t.Fatalf("save failure login = %v", result.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
oldClient := oauthHTTPClient
|
||||
oldRequest := oauthNewRequest
|
||||
oldRetry := oauthRetryAfter
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RefreshFailureClass separates refresh failures that may recover after a
|
||||
// delay from failures that require new credentials or local intervention.
|
||||
type RefreshFailureClass string
|
||||
|
||||
const (
|
||||
RefreshFailureUnknown RefreshFailureClass = "unknown"
|
||||
RefreshFailureTransient RefreshFailureClass = "transient"
|
||||
RefreshFailureTerminal RefreshFailureClass = "terminal"
|
||||
)
|
||||
|
||||
// HTTPStatusError preserves an OAuth endpoint status for structured retry
|
||||
// decisions without copying an untrusted response body into logs.
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
responseBody string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
if e == nil {
|
||||
return "OAuth endpoint request failed"
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d", e.StatusCode)
|
||||
}
|
||||
|
||||
func httpStatusResponseBody(err error) string {
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr == nil {
|
||||
return ""
|
||||
}
|
||||
return statusErr.responseBody
|
||||
}
|
||||
|
||||
// ClassifyRefreshFailure uses only structured transport and HTTP signals.
|
||||
// Unknown errors, including parse, keychain and persistence failures, remain
|
||||
// fatal so a long-running source cannot retry an error that needs user action.
|
||||
func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
if err == nil {
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return RefreshFailureTransient
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return RefreshFailureTransient
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return RefreshFailureTransient
|
||||
}
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr == nil {
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
if statusErr.StatusCode == http.StatusRequestTimeout ||
|
||||
statusErr.StatusCode == http.StatusTooManyRequests ||
|
||||
statusErr.StatusCode >= http.StatusInternalServerError {
|
||||
return RefreshFailureTransient
|
||||
}
|
||||
if statusErr.StatusCode == http.StatusBadRequest ||
|
||||
statusErr.StatusCode == http.StatusUnauthorized ||
|
||||
statusErr.StatusCode == http.StatusForbidden {
|
||||
return RefreshFailureTerminal
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want RefreshFailureClass
|
||||
}{
|
||||
{name: "deadline", err: context.DeadlineExceeded, want: RefreshFailureTransient},
|
||||
{name: "network", err: &url.Error{Op: "Post", URL: "https://oauth.test", Err: context.DeadlineExceeded}, want: RefreshFailureTransient},
|
||||
{name: "request timeout", err: &HTTPStatusError{StatusCode: http.StatusRequestTimeout}, want: RefreshFailureTransient},
|
||||
{name: "rate limited", err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, want: RefreshFailureTransient},
|
||||
{name: "server unavailable", err: &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}, want: RefreshFailureTransient},
|
||||
{name: "refresh rejected", err: &HTTPStatusError{StatusCode: http.StatusUnauthorized}, want: RefreshFailureTerminal},
|
||||
{name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal},
|
||||
{name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal},
|
||||
{name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown},
|
||||
{name: "nil error", err: nil, want: RefreshFailureUnknown},
|
||||
{name: "dns failure", err: &net.DNSError{Err: "no such host", Name: "oauth.test"}, want: RefreshFailureTransient},
|
||||
{name: "redirect status", err: &HTTPStatusError{StatusCode: http.StatusFound}, want: RefreshFailureUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ClassifyRefreshFailure(tt.err); got != tt.want {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *testing.T) {
|
||||
want := &HTTPStatusError{StatusCode: http.StatusTooManyRequests}
|
||||
err := errors.Join(errors.New("refresh failed"), want)
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %q, want transient", got)
|
||||
}
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("HTTP status error not retained: %v", err)
|
||||
}
|
||||
if got, want := statusErr.Error(), "HTTP 429"; got != want {
|
||||
t.Fatalf("HTTP status error = %q, want %q", got, want)
|
||||
}
|
||||
var nilStatus *HTTPStatusError
|
||||
if got, want := nilStatus.Error(), "OAuth endpoint request failed"; got != want {
|
||||
t.Fatalf("nil HTTP status error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthExchangeDisplayErrorFallsBackToPlainError(t *testing.T) {
|
||||
if got, want := oauthExchangeDisplayError(&HTTPStatusError{StatusCode: http.StatusBadGateway}), "HTTP 502: token exchange failed"; got != want {
|
||||
t.Fatalf("status display error = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := oauthExchangeDisplayError(errors.New("exchange failed")), "exchange failed"; got != want {
|
||||
t.Fatalf("plain display error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) {
|
||||
const secretBody = `{"refreshToken":"must-not-reach-logs"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(secretBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := &OAuthProvider{httpClient: server.Client()}
|
||||
_, err := provider.postJSON(context.Background(), server.URL, map[string]string{"grantType": "refresh_token"})
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %q, want transient: %v", got, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "must-not-reach-logs") {
|
||||
t.Fatalf("postJSON error leaked response body: %v", err)
|
||||
}
|
||||
if got := httpStatusResponseBody(err); !strings.Contains(got, "must-not-reach-logs") {
|
||||
t.Fatalf("postJSON did not retain bounded response details for internal classification: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientRefreshFailures(t *testing.T) {
|
||||
oldLoad := oauthLoadToken
|
||||
oldLoadLocked := oauthLoadTokenLocked
|
||||
oldAcquire := oauthAcquireLock
|
||||
oldRefresh := oauthRefreshToken
|
||||
oldMark := oauthMarkProfile
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
oauthLoadToken = oldLoad
|
||||
oauthLoadTokenLocked = oldLoadLocked
|
||||
oauthAcquireLock = oldAcquire
|
||||
oauthRefreshToken = oldRefresh
|
||||
oauthMarkProfile = oldMark
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
|
||||
expired := &TokenData{
|
||||
AccessToken: "expired-access",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshToken: "refresh",
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp",
|
||||
UserID: "user",
|
||||
}
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
|
||||
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return expired, nil }
|
||||
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
||||
|
||||
markCalls := 0
|
||||
oauthMarkProfile = func(_, _, status string) error {
|
||||
if status != ProfileStatusExpired {
|
||||
t.Fatalf("profile status = %q, want %q", status, ProfileStatusExpired)
|
||||
}
|
||||
markCalls++
|
||||
return nil
|
||||
}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}
|
||||
}
|
||||
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTransient {
|
||||
t.Fatalf("transient refresh error = %v", err)
|
||||
}
|
||||
if markCalls != 0 {
|
||||
t.Fatalf("transient refresh marked profile expired %d times", markCalls)
|
||||
}
|
||||
|
||||
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &HTTPStatusError{StatusCode: http.StatusUnauthorized}
|
||||
}
|
||||
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTerminal {
|
||||
t.Fatalf("terminal refresh error = %v", err)
|
||||
}
|
||||
if markCalls != 1 {
|
||||
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type rejectedTokenHookStore struct {
|
||||
mu sync.Mutex
|
||||
data TokenData
|
||||
deletes int
|
||||
}
|
||||
|
||||
func (s *rejectedTokenHookStore) load(string) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return json.Marshal(s.data)
|
||||
}
|
||||
|
||||
func (s *rejectedTokenHookStore) save(_ string, blob []byte) error {
|
||||
var data TokenData
|
||||
if err := json.Unmarshal(blob, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.data = data
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rejectedTokenHookStore) delete(string) error {
|
||||
s.mu.Lock()
|
||||
s.data = TokenData{}
|
||||
s.deletes++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rejectedTokenHookStore) snapshot() (TokenData, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data, s.deletes
|
||||
}
|
||||
|
||||
func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore {
|
||||
t.Helper()
|
||||
store := &rejectedTokenHookStore{data: data}
|
||||
previousHooks := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: store.load,
|
||||
SaveToken: store.save,
|
||||
DeleteToken: store.delete,
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(previousHooks) })
|
||||
return store
|
||||
}
|
||||
|
||||
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
|
||||
t.Helper()
|
||||
resetRejectedTokenRefreshCoordinator(t)
|
||||
previous := oauthRefreshToken
|
||||
oauthRefreshToken = fn
|
||||
t.Cleanup(func() { oauthRefreshToken = previous })
|
||||
}
|
||||
|
||||
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
|
||||
t.Helper()
|
||||
reset := func() {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall)
|
||||
rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure)
|
||||
rejectedTokenRefreshCoordinator.now = time.Now
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
call := rejectedTokenRefreshCoordinator.inFlight[key]
|
||||
got := 0
|
||||
if call != nil {
|
||||
got = call.participants
|
||||
}
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
if got >= want {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("refresh participants = %d, want %d", got, want)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
|
||||
t.Helper()
|
||||
previous := profilesAcquireDualLock
|
||||
attempted := make(chan struct{}, 1)
|
||||
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||
attempted <- struct{}{}
|
||||
return previous(ctx, configDir)
|
||||
}
|
||||
t.Cleanup(func() { profilesAcquireDualLock = previous })
|
||||
return attempted
|
||||
}
|
||||
|
||||
func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-attempted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("public opaque token mutation did not enter the Core dual lock")
|
||||
}
|
||||
}
|
||||
|
||||
func validRejectedTokenData(accessToken string) TokenData {
|
||||
return TokenData{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
Source: "mcp",
|
||||
ClientID: "client-id",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
var refreshCalls atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
close(started)
|
||||
}
|
||||
<-release
|
||||
updated := *data
|
||||
updated.AccessToken = "new-access"
|
||||
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
})
|
||||
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
const workers = 8
|
||||
results := make(chan string, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
results <- token
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
<-started
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for token := range results {
|
||||
if token != "new-access" {
|
||||
t.Fatalf("token = %q, want new-access", token)
|
||||
}
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", got)
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "new-access" || deletes != 0 {
|
||||
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
refreshErr := errors.New("temporary refresh failure")
|
||||
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, refreshErr
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
if !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("error = %v, want refresh cause", err)
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
|
||||
t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
previousProfile := RuntimeProfile()
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
|
||||
|
||||
refreshErr := errors.New("temporary refresh failure")
|
||||
var refreshCalls atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
baseNow := time.Now()
|
||||
var nowNanos atomic.Int64
|
||||
nowNanos.Store(baseNow.UnixNano())
|
||||
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
call := refreshCalls.Add(1)
|
||||
if call == 1 {
|
||||
close(started)
|
||||
<-release
|
||||
return nil, refreshErr
|
||||
}
|
||||
updated := *data
|
||||
updated.AccessToken = "recovered-access"
|
||||
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
})
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
rejectedTokenRefreshCoordinator.now = func() time.Time {
|
||||
return time.Unix(0, nowNanos.Load())
|
||||
}
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
|
||||
configDir := t.TempDir()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
const workers = 8
|
||||
start := make(chan struct{})
|
||||
ready := make(chan struct{}, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ready <- struct{}{}
|
||||
<-start
|
||||
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
for range workers {
|
||||
<-ready
|
||||
}
|
||||
close(start)
|
||||
<-started
|
||||
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
|
||||
waitForRejectedTokenRefreshParticipants(t, key, workers)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
if !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("shared refresh error = %v, want %v", err, refreshErr)
|
||||
}
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("refresh calls after concurrent failure = %d, want 1", got)
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
|
||||
t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes)
|
||||
}
|
||||
|
||||
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, refreshErr)
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("refresh calls inside cooldown = %d, want 1", got)
|
||||
}
|
||||
|
||||
nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano())
|
||||
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
if err != nil || token != "recovered-access" {
|
||||
t.Fatalf("refresh after cooldown = %q, %v", token, err)
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 2 {
|
||||
t.Fatalf("refresh calls after cooldown = %d, want 2", got)
|
||||
}
|
||||
stored, deletes = store.snapshot()
|
||||
if stored.AccessToken != "recovered-access" || deletes != 0 {
|
||||
t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
previousProfile := RuntimeProfile()
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
|
||||
|
||||
refreshErr := errors.New("temporary refresh failure")
|
||||
var refreshCalls atomic.Int32
|
||||
baseNow := time.Now()
|
||||
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls.Add(1)
|
||||
return nil, refreshErr
|
||||
})
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow }
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
|
||||
configDir := t.TempDir()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("initial refresh error = %v, want %v", err, refreshErr)
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("initial refresh calls = %d, want 1", got)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
store.data = validRejectedTokenData("externally-refreshed")
|
||||
store.mu.Unlock()
|
||||
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
if err != nil || token != "externally-refreshed" {
|
||||
t.Fatalf("refresh after external publication = %q, %v", token, err)
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("external publication triggered another exchange: calls=%d", got)
|
||||
}
|
||||
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
|
||||
rejectedTokenRefreshCoordinator.Lock()
|
||||
_, failurePresent := rejectedTokenRefreshCoordinator.failures[key]
|
||||
rejectedTokenRefreshCoordinator.Unlock()
|
||||
if failurePresent {
|
||||
t.Fatal("old-token failure cache was not cleared after external publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
|
||||
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
close(started)
|
||||
<-release
|
||||
updated := *data
|
||||
updated.AccessToken = "refreshed-from-old"
|
||||
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
refreshResult := make(chan struct {
|
||||
token string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
refreshResult <- struct {
|
||||
token string
|
||||
err error
|
||||
}{token: token, err: err}
|
||||
}()
|
||||
<-started
|
||||
|
||||
acquireAttempted := installProfilesAcquireProbe(t)
|
||||
publishResult := make(chan error, 1)
|
||||
go func() {
|
||||
publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published")))
|
||||
}()
|
||||
waitForProfilesAcquire(t, acquireAttempted)
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
|
||||
refresh := <-refreshResult
|
||||
if refresh.err != nil || refresh.token != "refreshed-from-old" {
|
||||
t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err)
|
||||
}
|
||||
if err := <-publishResult; err != nil {
|
||||
t.Fatalf("publish token: %v", err)
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "login-published" || deletes != 0 {
|
||||
t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
logout func(string) error
|
||||
}{
|
||||
{name: "current profile", logout: func(configDir string) error {
|
||||
return DeleteTokenDataForProfile(configDir, "")
|
||||
}},
|
||||
{name: "all profiles", logout: DeleteAllTokenData},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
|
||||
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
close(started)
|
||||
<-release
|
||||
updated := *data
|
||||
updated.AccessToken = "refreshed-before-logout"
|
||||
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
refreshResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||
refreshResult <- err
|
||||
}()
|
||||
<-started
|
||||
|
||||
acquireAttempted := installProfilesAcquireProbe(t)
|
||||
logoutResult := make(chan error, 1)
|
||||
go func() { logoutResult <- tc.logout(configDir) }()
|
||||
waitForProfilesAcquire(t, acquireAttempted)
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
|
||||
if err := <-refreshResult; err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if err := <-logoutResult; err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 {
|
||||
t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTokenData(data TokenData) *TokenData {
|
||||
return &data
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) {
|
||||
data := validRejectedTokenData("expired-access")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
store := installRejectedTokenHookStore(t, data)
|
||||
var refreshCalls atomic.Int32
|
||||
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) {
|
||||
refreshCalls.Add(1)
|
||||
updated := *current
|
||||
updated.AccessToken = "proactively-refreshed"
|
||||
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
})
|
||||
|
||||
token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background())
|
||||
if err != nil || token != "proactively-refreshed" {
|
||||
t.Fatalf("GetAccessToken() = %q, %v", token, err)
|
||||
}
|
||||
if refreshCalls.Load() != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load())
|
||||
}
|
||||
stored, deletes := store.snapshot()
|
||||
if stored.AccessToken != "proactively-refreshed" || deletes != 0 {
|
||||
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -34,7 +35,17 @@ func TestMain(m *testing.M) {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err)
|
||||
if code == 0 {
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
+37
-11
@@ -127,6 +127,10 @@ const tokenJSONFile = "token.json"
|
||||
type TokenMarker struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ManualToken bool `json:"manual_token,omitempty"`
|
||||
// Revision changes on every credential publication. Runtime token caches
|
||||
// use it as a cheap cross-process invalidation signal without reading the
|
||||
// platform keychain on every request.
|
||||
Revision string `json:"revision,omitempty"`
|
||||
}
|
||||
|
||||
// WriteTokenMarker writes a token.json marker containing only an updated_at
|
||||
@@ -147,6 +151,7 @@ func writeTokenMarker(configDir string, manual bool) error {
|
||||
marker := TokenMarker{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
ManualToken: manual,
|
||||
Revision: uuid.NewString(),
|
||||
}
|
||||
data, _ := tokenJSONMarshalIndent(marker, "", " ")
|
||||
if err := tokenMkdirAll(configDir, 0o700); err != nil {
|
||||
@@ -159,6 +164,27 @@ func writeTokenMarker(configDir string, manual bool) error {
|
||||
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
|
||||
}
|
||||
|
||||
// ReadTokenMarkerRevision returns the current credential publication revision.
|
||||
// Existing markers without a revision remain readable, but callers must avoid
|
||||
// caching them because they cannot prove that the credential is unchanged.
|
||||
func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) {
|
||||
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("read token marker: %w", err)
|
||||
}
|
||||
var marker TokenMarker
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
// The marker is only a cache-coherency hint. A malformed historical or
|
||||
// externally modified marker must disable caching, not make an otherwise
|
||||
// valid credential unusable.
|
||||
return "", true, nil
|
||||
}
|
||||
return strings.TrimSpace(marker.Revision), true, nil
|
||||
}
|
||||
|
||||
func manualTokenMarkerActive(configDir string) (bool, error) {
|
||||
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||
if err != nil {
|
||||
@@ -184,13 +210,10 @@ func DeleteTokenMarker(configDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to the default keychain-based storage.
|
||||
// SaveTokenData persists TokenData under the auth dual lock. When an edition
|
||||
// hook (SaveToken) is registered, the locked write delegates to that hook;
|
||||
// otherwise it falls back to the default keychain-based storage.
|
||||
func SaveTokenData(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return saveTokenDataLocked(configDir, data)
|
||||
})
|
||||
@@ -464,9 +487,8 @@ func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
||||
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.
|
||||
// DeleteTokenData removes token data. Edition hooks and the default keychain
|
||||
// path are both serialized with refresh through the auth dual lock.
|
||||
func DeleteTokenData(configDir string) error {
|
||||
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
@@ -478,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
return h.DeleteToken(configDir)
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return h.DeleteToken(configDir)
|
||||
})
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return deleteTokenDataForProfileLocked(configDir, profile)
|
||||
@@ -913,7 +937,9 @@ func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error {
|
||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||
func DeleteAllTokenData(configDir string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
return h.DeleteToken(configDir)
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return h.DeleteToken(configDir)
|
||||
})
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
var firstErr error
|
||||
|
||||
@@ -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:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
|
||||
"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:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
|
||||
"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": [
|
||||
@@ -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:afe01e0b6029a2be43669927798a02ca585f44b0e7cb1a31e8e2ee0c636e73ee",
|
||||
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 22,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
|
||||
"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")
|
||||
|
||||
@@ -31,6 +31,31 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type consumeReadyWriter struct {
|
||||
ready chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newConsumeReadyWriter() *consumeReadyWriter {
|
||||
return &consumeReadyWriter{ready: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (w *consumeReadyWriter) Write(p []byte) (int, error) {
|
||||
if bytes.Contains(p, []byte("[event] ready ")) {
|
||||
w.once.Do(func() { close(w.ready) })
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func waitForConsumerReady(t *testing.T, ready <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("consumer did not report ready")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time
|
||||
// event_types pushdown contract (plan §4 unsung superpower): a consumer
|
||||
// subscribing to "im.*" must NOT receive "approval.*" events even when
|
||||
@@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) {
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var imBuf, approvalBuf bytes.Buffer
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
imReady := newConsumeReadyWriter()
|
||||
approvalReady := newConsumeReadyWriter()
|
||||
consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelConsumers()
|
||||
consumeDone := make(chan error, 2)
|
||||
|
||||
// Consumer A: im.* only
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = Run(context.Background(), Config{
|
||||
consumeDone <- Run(consumeCtx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &imBuf,
|
||||
Stderr: io.Discard,
|
||||
Stderr: imReady,
|
||||
EventTypes: []string{"im.*"},
|
||||
MaxEvents: 3, // 3 im events expected
|
||||
})
|
||||
}()
|
||||
// Consumer B: approval.* only
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = Run(context.Background(), Config{
|
||||
consumeDone <- Run(consumeCtx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &approvalBuf,
|
||||
Stderr: io.Discard,
|
||||
Stderr: approvalReady,
|
||||
EventTypes: []string{"approval.*"},
|
||||
MaxEvents: 2, // 2 approval events expected
|
||||
})
|
||||
}()
|
||||
|
||||
// Give both consumers time to Hello + register.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
waitForConsumerReady(t, imReady.ready)
|
||||
waitForConsumerReady(t, approvalReady.ready)
|
||||
close(trigger)
|
||||
wg.Wait()
|
||||
for range 2 {
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("consume failed: %v", err)
|
||||
}
|
||||
case <-consumeCtx.Done():
|
||||
t.Fatalf("consumers did not finish: %v", consumeCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify consumer A got exactly the 3 im.* events.
|
||||
imLines := nonEmptyLines(imBuf.String())
|
||||
@@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) {
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
consumeDone := make(chan struct{})
|
||||
ready := newConsumeReadyWriter()
|
||||
consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelConsumer()
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(consumeDone)
|
||||
_ = Run(context.Background(), Config{
|
||||
consumeDone <- Run(consumeCtx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &buf,
|
||||
Stderr: io.Discard,
|
||||
Stderr: ready,
|
||||
EventTypes: []string{"im.*"},
|
||||
Filter: `\.at_v1$`, // only at_v1 events
|
||||
MaxEvents: 1,
|
||||
})
|
||||
}()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
waitForConsumerReady(t, ready.ready)
|
||||
close(trigger)
|
||||
<-consumeDone
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("consume failed: %v", err)
|
||||
}
|
||||
case <-consumeCtx.Done():
|
||||
t.Fatalf("consumer did not finish: %v", consumeCtx.Err())
|
||||
}
|
||||
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -63,9 +63,10 @@ func (i Identity) Key() string {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Identity Identity
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Identity Identity
|
||||
AccessTokenProvider func(context.Context) (string, error)
|
||||
}
|
||||
|
||||
type CreateSubscriptionRequest struct {
|
||||
@@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
if c == nil {
|
||||
return errors.New("personal event: nil client")
|
||||
}
|
||||
if c.Identity.AccessToken == "" {
|
||||
return errors.New("personal event: access token is required")
|
||||
accessToken, err := c.resolveAccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := strings.TrimRight(c.BaseURL, "/") + path
|
||||
if len(q) > 0 {
|
||||
@@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
if err != nil {
|
||||
return fmt.Errorf("personal event: create request: %w", err)
|
||||
}
|
||||
c.decorate(req)
|
||||
c.decorate(req, accessToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func (c *Client) decorate(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken)
|
||||
req.Header.Set("x-user-access-token", c.Identity.AccessToken)
|
||||
func (c *Client) resolveAccessToken(ctx context.Context) (string, error) {
|
||||
if c.AccessTokenProvider != nil {
|
||||
token, err := c.AccessTokenProvider(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("personal event: resolve access token: %w", err)
|
||||
}
|
||||
if token = strings.TrimSpace(token); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
return "", errors.New("personal event: access token provider returned empty token")
|
||||
}
|
||||
if token := strings.TrimSpace(c.Identity.AccessToken); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
return "", errors.New("personal event: access token is required")
|
||||
}
|
||||
|
||||
func (c *Client) decorate(req *http.Request, accessToken string) {
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
|
||||
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
|
||||
if c.Identity.CorpID != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package personal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type accessTokenRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) {
|
||||
tokens := []string{"token-a", "token-b"}
|
||||
calls := 0
|
||||
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
|
||||
client.AccessTokenProvider = func(context.Context) (string, error) {
|
||||
token := tokens[calls]
|
||||
calls++
|
||||
return token, nil
|
||||
}
|
||||
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
want := tokens[calls-1]
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer "+want {
|
||||
t.Fatalf("Authorization = %q, want Bearer %s", got, want)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
for range 2 {
|
||||
if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) {
|
||||
want := errors.New("keychain failed")
|
||||
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
|
||||
client.AccessTokenProvider = func(context.Context) (string, error) { return "", want }
|
||||
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("HTTP must not run after token provider failure")
|
||||
return nil, nil
|
||||
})}
|
||||
_, err := client.ListSubscriptions(context.Background(), ListOptions{})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func TestCrossPlatformCoveragePortalStartEndToEndAndFailures(t *testing.T) {
|
||||
return s
|
||||
}
|
||||
network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })}
|
||||
s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network})
|
||||
s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network, DisableReconnect: true})
|
||||
if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
|
||||
t.Fatal("ticket failure expected")
|
||||
}
|
||||
@@ -483,7 +483,7 @@ func TestCrossPlatformCoveragePortalStartHandshakeReadAndAckErrors(t *testing.T)
|
||||
makeSource := func(endpoint string) *DingtalkSource {
|
||||
s, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: "https://ticket", AccessToken: "token", SourceID: "source",
|
||||
HTTPClient: staticPersonalTicketClient(endpoint, ""),
|
||||
HTTPClient: staticPersonalTicketClient(endpoint, ""), DisableReconnect: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -41,19 +42,30 @@ const (
|
||||
)
|
||||
|
||||
type PersonalConfig struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
SourceID string
|
||||
TicketURL string
|
||||
TicketMode string
|
||||
HTTPClient *http.Client
|
||||
WebSocketDialer *websocket.Dialer
|
||||
Now func() time.Time
|
||||
ReconnectMin time.Duration
|
||||
ReconnectMax time.Duration
|
||||
AccessToken string
|
||||
AccessTokenProvider AccessTokenProvider
|
||||
ForceRefreshToken ForceRefreshTokenFn
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
SourceID string
|
||||
TicketURL string
|
||||
TicketMode string
|
||||
HTTPClient *http.Client
|
||||
WebSocketDialer *websocket.Dialer
|
||||
Now func() time.Time
|
||||
ReconnectMin time.Duration
|
||||
ReconnectMax time.Duration
|
||||
}
|
||||
|
||||
type AccessTokenProvider func(context.Context) (string, error)
|
||||
|
||||
// ForceRefreshTokenFn rotates an access token that the server has just
|
||||
// rejected (HTTP 401). It receives the exact rejected token so the caller's
|
||||
// compare-and-refresh logic can skip the refresh when another goroutine has
|
||||
// already rotated it, and returns the fresh token to retry with. Optional:
|
||||
// when nil a 401 stays fatal, matching the previous behavior.
|
||||
type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error)
|
||||
|
||||
type PersonalSource struct {
|
||||
cfg PersonalConfig
|
||||
machine *Machine
|
||||
@@ -73,8 +85,8 @@ type ticketResponse struct {
|
||||
}
|
||||
|
||||
func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) {
|
||||
if strings.TrimSpace(cfg.AccessToken) == "" {
|
||||
return nil, errors.New("personal source: AccessToken is required")
|
||||
if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" {
|
||||
return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
return nil, errors.New("personal source: ClientID is required")
|
||||
@@ -192,6 +204,31 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) (
|
||||
}
|
||||
|
||||
func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) {
|
||||
accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source")
|
||||
if err != nil {
|
||||
// Transient provider failures (network, 429, 5xx) must not kill a
|
||||
// long-running source; the reconnect loop retries after backoff.
|
||||
if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient {
|
||||
return nil, retryPersonal(err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ticket, status, err := s.fetchTicketAttempt(ctx, accessToken)
|
||||
if status == http.StatusUnauthorized && s.cfg.ForceRefreshToken != nil {
|
||||
refreshed, refreshErr := refreshRejectedSourceToken(ctx, s.cfg.ForceRefreshToken, accessToken, "personal source", err)
|
||||
if refreshErr != nil {
|
||||
if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient {
|
||||
return nil, retryPersonal(refreshErr)
|
||||
}
|
||||
return nil, refreshErr
|
||||
}
|
||||
// Retry once with the freshly rotated token; a second 401 stays fatal.
|
||||
ticket, _, err = s.fetchTicketAttempt(ctx, refreshed)
|
||||
}
|
||||
return ticket, err
|
||||
}
|
||||
|
||||
func (s *PersonalSource) fetchTicketAttempt(ctx context.Context, accessToken string) (*ticketResponse, int, error) {
|
||||
body := map[string]any{
|
||||
"sourceId": s.cfg.SourceID,
|
||||
"mode": s.cfg.TicketMode,
|
||||
@@ -203,39 +240,78 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personal source: create ticket request: %w", err)
|
||||
return nil, 0, fmt.Errorf("personal source: create ticket request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("x-user-access-token", s.cfg.AccessToken)
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken)
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID)
|
||||
req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID)
|
||||
|
||||
resp, err := s.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err))
|
||||
return nil, 0, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Classify by status before touching the body: a truncated error body
|
||||
// must not upgrade a fatal status (notably 401) into a retryable
|
||||
// error, or the outer reconnect loop would bypass the single
|
||||
// refresh-retry guard. The body is not used here, so drain it only
|
||||
// best-effort for connection reuse.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode)
|
||||
if retryableTicketStatus(resp.StatusCode) {
|
||||
return nil, retryPersonal(err)
|
||||
return nil, resp.StatusCode, retryPersonal(err)
|
||||
}
|
||||
return nil, err
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err))
|
||||
}
|
||||
ticket, err := decodeTicket(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
if ticket.Endpoint == "" || ticket.Ticket == "" {
|
||||
return nil, errors.New("personal source: ticket response missing endpoint or ticket")
|
||||
return nil, resp.StatusCode, errors.New("personal source: ticket response missing endpoint or ticket")
|
||||
}
|
||||
return ticket, nil
|
||||
return ticket, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// refreshRejectedSourceToken funnels a server-side 401 into the optional
|
||||
// force-refresh callback. It hands the actual rejected token to the caller's
|
||||
// compare-and-refresh logic and returns the rotated token for an immediate
|
||||
// retry. Refresh failures keep the original 401 as context instead of being
|
||||
// dropped.
|
||||
func refreshRejectedSourceToken(ctx context.Context, refresh ForceRefreshTokenFn, rejectedToken, component string, cause error) (string, error) {
|
||||
token, err := refresh(ctx, rejectedToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: refresh rejected access token: %w", component, errors.Join(cause, err))
|
||||
}
|
||||
if token = strings.TrimSpace(token); token == "" {
|
||||
return "", fmt.Errorf("%s: refresh rejected access token returned empty token: %w", component, cause)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) {
|
||||
if provider != nil {
|
||||
token, err := provider(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: resolve access token: %w", component, err)
|
||||
}
|
||||
if token = strings.TrimSpace(token); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s: access token provider returned empty token", component)
|
||||
}
|
||||
if token := strings.TrimSpace(fallback); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s: access token is required", component)
|
||||
}
|
||||
|
||||
func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error {
|
||||
@@ -357,6 +433,13 @@ func isRetryablePersonalError(err error) bool {
|
||||
func personalRetryLogError(err error) string {
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "resolve access token"), strings.Contains(message, "refresh rejected access token"):
|
||||
// Token resolution/refresh errors may carry provider details; log
|
||||
// only the structured HTTP status.
|
||||
if status := refreshHTTPStatus(err); status != 0 {
|
||||
return fmt.Sprintf("personal source: token refresh HTTP %d", status)
|
||||
}
|
||||
return "personal source: token refresh: temporary network error"
|
||||
case strings.Contains(message, "ticket HTTP"):
|
||||
return message
|
||||
case strings.Contains(message, "fetch ticket"):
|
||||
@@ -374,6 +457,14 @@ func personalRetryLogError(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshHTTPStatus(err error) int {
|
||||
var statusErr *authpkg.HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr == nil {
|
||||
return 0
|
||||
}
|
||||
return statusErr.StatusCode
|
||||
}
|
||||
|
||||
func retryableTicketStatus(status int) bool {
|
||||
return status == http.StatusRequestTimeout ||
|
||||
status == http.StatusTooManyRequests ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,11 +20,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
@@ -33,20 +35,56 @@ import (
|
||||
const (
|
||||
PortalTicketModeNormal = "normal"
|
||||
PortalTicketModeCustom = "custom"
|
||||
portalReconnectMin = time.Second
|
||||
portalReconnectMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// PortalTicketConfig describes the portal-managed user Stream ticket flow.
|
||||
// normal mode uses portal-side managed credentials; custom mode asks portal to
|
||||
// open the user connection with the caller-provided clientId/clientSecret.
|
||||
type PortalTicketConfig struct {
|
||||
TicketURL string
|
||||
AccessToken string
|
||||
SourceID string
|
||||
Mode string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
TicketURL string
|
||||
AccessToken string
|
||||
AccessTokenProvider AccessTokenProvider
|
||||
ForceRefreshToken ForceRefreshTokenFn
|
||||
SourceID string
|
||||
Mode string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
WebSocketDialer *websocket.Dialer
|
||||
ReconnectMin time.Duration
|
||||
ReconnectMax time.Duration
|
||||
DisableReconnect bool
|
||||
}
|
||||
|
||||
// portalStageError tags a portal stream failure with the stage it happened
|
||||
// in and whether the reconnect loop may retry it. Error() stays free of
|
||||
// untrusted response content so it is safe to log on every reconnect.
|
||||
type portalStageError struct {
|
||||
stage string
|
||||
status int
|
||||
retryable bool
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *portalStageError) Error() string {
|
||||
if e == nil {
|
||||
return "source: portal stream failed"
|
||||
}
|
||||
message := "source: portal " + strings.ReplaceAll(strings.TrimSpace(e.stage), "_", " ") + " failed"
|
||||
if e.status != 0 {
|
||||
message += fmt.Sprintf(" (HTTP %d)", e.status)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func (e *portalStageError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.cause
|
||||
}
|
||||
|
||||
var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error {
|
||||
@@ -60,8 +98,8 @@ func (c *PortalTicketConfig) Valid() error {
|
||||
if strings.TrimSpace(c.TicketURL) == "" {
|
||||
return errors.New("source: portal ticket URL is required")
|
||||
}
|
||||
if strings.TrimSpace(c.AccessToken) == "" {
|
||||
return errors.New("source: portal access token is required")
|
||||
if c.AccessTokenProvider == nil && strings.TrimSpace(c.AccessToken) == "" {
|
||||
return errors.New("source: portal access token or provider is required")
|
||||
}
|
||||
if strings.TrimSpace(c.SourceID) == "" {
|
||||
return errors.New("source: portal sourceId is required")
|
||||
@@ -96,48 +134,102 @@ func normalizePortalTicketMode(mode string) string {
|
||||
|
||||
func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.EmitFn) error {
|
||||
s.machine.OnConnecting()
|
||||
defer s.machine.OnStopped()
|
||||
|
||||
minBackoff := s.cfg.PortalTicket.ReconnectMin
|
||||
if minBackoff <= 0 {
|
||||
minBackoff = portalReconnectMin
|
||||
}
|
||||
maxBackoff := s.cfg.PortalTicket.ReconnectMax
|
||||
if maxBackoff <= 0 {
|
||||
maxBackoff = portalReconnectMax
|
||||
}
|
||||
if maxBackoff < minBackoff {
|
||||
maxBackoff = minBackoff
|
||||
}
|
||||
backoff := minBackoff
|
||||
for {
|
||||
acked, err := s.runPortalTicketAttempt(ctx, emit)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
var stageErr *portalStageError
|
||||
if !errors.As(err, &stageErr) || stageErr == nil || !stageErr.retryable || s.cfg.PortalTicket.DisableReconnect {
|
||||
return err
|
||||
}
|
||||
if acked {
|
||||
backoff = minBackoff
|
||||
}
|
||||
s.machine.OnReconnect()
|
||||
slog.Warn("portal source reconnecting",
|
||||
"stage", stageErr.stage,
|
||||
"http_status", stageErr.status,
|
||||
"error_type", fmt.Sprintf("%T", stageErr.cause),
|
||||
"retry_in", backoff,
|
||||
"reconnect_count", s.machine.Snapshot().ReconnectCount,
|
||||
)
|
||||
if err := waitPersonalReconnect(ctx, backoff); err != nil {
|
||||
return err
|
||||
}
|
||||
backoff = nextPersonalBackoff(backoff, maxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DingtalkSource) runPortalTicketAttempt(ctx context.Context, emit dwsevent.EmitFn) (bool, error) {
|
||||
ticket, err := requestPortalTicket(ctx, s.cfg.PortalTicket)
|
||||
if err != nil {
|
||||
s.machine.OnStopped()
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
wsURL, err := websocketURL(ticket)
|
||||
if err != nil {
|
||||
s.machine.OnStopped()
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
userAgent := strings.TrimSpace(s.cfg.PortalTicket.UserAgent)
|
||||
if userAgent == "" {
|
||||
userAgent = "dws-event-consume"
|
||||
}
|
||||
conn, resp, err := (&websocket.Dialer{HandshakeTimeout: 20 * time.Second}).DialContext(ctx, wsURL, http.Header{
|
||||
dialer := s.cfg.PortalTicket.WebSocketDialer
|
||||
if dialer == nil {
|
||||
dialer = &websocket.Dialer{HandshakeTimeout: 20 * time.Second}
|
||||
}
|
||||
conn, resp, err := dialer.DialContext(ctx, wsURL, http.Header{
|
||||
"User-Agent": []string{userAgent},
|
||||
})
|
||||
if err != nil {
|
||||
s.machine.OnStopped()
|
||||
status := 0
|
||||
cause := fmt.Errorf("source: portal stream connect: %w", err)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
status = resp.StatusCode
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return fmt.Errorf("source: portal stream connect HTTP %d: %s: %w",
|
||||
cause = fmt.Errorf("source: portal stream connect HTTP %d: %s: %w",
|
||||
resp.StatusCode, truncatePortalTicketLog(string(raw), 300), err)
|
||||
}
|
||||
return fmt.Errorf("source: portal stream connect: %w", err)
|
||||
return false, &portalStageError{
|
||||
stage: "stream_connect",
|
||||
status: status,
|
||||
retryable: status == 0 || retryableTicketStatus(status),
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
attemptCtx, cancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
closeOnContext(attemptCtx, conn)
|
||||
s.machine.OnConnected()
|
||||
|
||||
closeOnContext(ctx, conn)
|
||||
handler := s.makeHandler(emit)
|
||||
acked := false
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
s.machine.OnStopped()
|
||||
if isContextDone(ctx) {
|
||||
return ctx.Err()
|
||||
return acked, ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("source: portal stream read: %w", err)
|
||||
return acked, &portalStageError{stage: "stream_read", retryable: true, cause: fmt.Errorf("source: portal stream read: %w", err)}
|
||||
}
|
||||
df, err := payload.DecodeDataFrame(message)
|
||||
if err != nil {
|
||||
@@ -146,12 +238,12 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em
|
||||
resp, _ := handler(ctx, df)
|
||||
ensurePortalAckHeaders(resp, df)
|
||||
if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil {
|
||||
s.machine.OnStopped()
|
||||
if isContextDone(ctx) {
|
||||
return ctx.Err()
|
||||
return acked, ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("source: portal stream ack: %w", err)
|
||||
return acked, &portalStageError{stage: "stream_ack", retryable: true, cause: fmt.Errorf("source: portal stream ack: %w", err)}
|
||||
}
|
||||
acked = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +253,35 @@ type portalStreamTicket struct {
|
||||
}
|
||||
|
||||
func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) {
|
||||
accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket")
|
||||
if err != nil {
|
||||
// Transient provider failures (network, 429, 5xx) must not kill a
|
||||
// long-running source; the reconnect loop retries after backoff.
|
||||
if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient {
|
||||
return portalStreamTicket{}, &portalStageError{stage: "ticket_auth", status: refreshHTTPStatus(err), retryable: true, cause: err}
|
||||
}
|
||||
return portalStreamTicket{}, err
|
||||
}
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
ticket, status, err := requestPortalTicketAttempt(ctx, cfg, httpClient, accessToken)
|
||||
if status == http.StatusUnauthorized && cfg.ForceRefreshToken != nil {
|
||||
refreshed, refreshErr := refreshRejectedSourceToken(ctx, cfg.ForceRefreshToken, accessToken, "source: portal ticket", err)
|
||||
if refreshErr != nil {
|
||||
if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient {
|
||||
return portalStreamTicket{}, &portalStageError{stage: "ticket_auth_refresh", status: refreshHTTPStatus(refreshErr), retryable: true, cause: refreshErr}
|
||||
}
|
||||
return portalStreamTicket{}, refreshErr
|
||||
}
|
||||
// Retry once with the freshly rotated token; a second 401 stays fatal.
|
||||
ticket, _, err = requestPortalTicketAttempt(ctx, cfg, httpClient, refreshed)
|
||||
}
|
||||
return ticket, err
|
||||
}
|
||||
|
||||
func requestPortalTicketAttempt(ctx context.Context, cfg *PortalTicketConfig, httpClient *http.Client, accessToken string) (portalStreamTicket, int, error) {
|
||||
body := map[string]string{
|
||||
"sourceId": strings.TrimSpace(cfg.SourceID),
|
||||
"channelType": strings.TrimSpace(cfg.SourceID),
|
||||
@@ -177,29 +294,45 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
|
||||
rawBody, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody))
|
||||
if err != nil {
|
||||
return portalStreamTicket{}, err
|
||||
return portalStreamTicket{}, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if ua := strings.TrimSpace(cfg.UserAgent); ua != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", cfg.AccessToken)
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return portalStreamTicket{}, fmt.Errorf("source: portal ticket request: %w", err)
|
||||
return portalStreamTicket{}, 0, &portalStageError{stage: "ticket_request", retryable: true, cause: fmt.Errorf("source: portal ticket request: %w", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return portalStreamTicket{}, fmt.Errorf("source: portal ticket HTTP %d: %s",
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Preserve HTTP status semantics before touching the body. A truncated
|
||||
// 401 body must not become a retryable read error that bypasses the
|
||||
// single token-refresh guard; the body is only best-effort diagnostics.
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
httpErr := fmt.Errorf("source: portal ticket HTTP %d: %s",
|
||||
resp.StatusCode, truncatePortalTicketLog(string(raw), 300))
|
||||
if retryableTicketStatus(resp.StatusCode) {
|
||||
return portalStreamTicket{}, resp.StatusCode, &portalStageError{stage: "ticket_request", status: resp.StatusCode, retryable: true, cause: httpErr}
|
||||
}
|
||||
return portalStreamTicket{}, resp.StatusCode, httpErr
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return portalStreamTicket{}, resp.StatusCode, &portalStageError{
|
||||
stage: "ticket_request",
|
||||
status: resp.StatusCode,
|
||||
retryable: true,
|
||||
cause: fmt.Errorf("source: portal ticket read: %w", readErr),
|
||||
}
|
||||
}
|
||||
|
||||
var direct portalStreamTicket
|
||||
if err := json.Unmarshal(raw, &direct); err == nil && direct.Endpoint != "" && direct.Ticket != "" {
|
||||
return direct, nil
|
||||
return direct, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
@@ -209,16 +342,16 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return portalStreamTicket{}, fmt.Errorf("source: portal ticket parse: %w", err)
|
||||
return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket parse: %w", err)
|
||||
}
|
||||
if !envelope.Success {
|
||||
return portalStreamTicket{}, fmt.Errorf("source: portal ticket failed: %s %s",
|
||||
return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket failed: %s %s",
|
||||
envelope.ErrorCode, envelope.ErrorMsg)
|
||||
}
|
||||
if envelope.Result.Endpoint == "" || envelope.Result.Ticket == "" {
|
||||
return portalStreamTicket{}, errors.New("source: portal ticket result missing endpoint/ticket")
|
||||
return portalStreamTicket{}, resp.StatusCode, errors.New("source: portal ticket result missing endpoint/ticket")
|
||||
}
|
||||
return envelope.Result, nil
|
||||
return envelope.Result, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func websocketURL(ticket portalStreamTicket) (string, error) {
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd drives the full production chain
|
||||
// DingtalkSource.Start → startPortalTicket → requestPortalTicket: the first
|
||||
// ticket request is rejected with 401, ForceRefreshToken rotates the token,
|
||||
// the in-chain retry succeeds with the fresh token and a WebSocket event is
|
||||
// delivered to emit.
|
||||
func TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd(t *testing.T) {
|
||||
var ticketCalls, refreshCalls atomic.Int64
|
||||
var rejectedSeen atomic.Value
|
||||
|
||||
upgrader := websocket.Upgrader{}
|
||||
var wsURL string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) {
|
||||
ticketCalls.Add(1)
|
||||
switch r.Header.Get("x-user-access-token") {
|
||||
case "fresh-token":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]string{"endpoint": wsURL, "ticket": "ticket-1"},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, "token expired")
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg-1"}, Data: `{}`}
|
||||
_ = conn.WriteJSON(df)
|
||||
_, _, _ = conn.ReadMessage()
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
||||
|
||||
s, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: srv.URL + "/ticket",
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
return "stale-token", nil
|
||||
},
|
||||
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
|
||||
refreshCalls.Add(1)
|
||||
rejectedSeen.Store(rejectedToken)
|
||||
return "fresh-token", nil
|
||||
},
|
||||
SourceID: "source",
|
||||
HTTPClient: srv.Client(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
emitted := make(chan struct{}, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Start(ctx, func(*dwsevent.RawEvent) { emitted <- struct{}{} }) }()
|
||||
select {
|
||||
case <-emitted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("portal event timeout after 401 refresh retry")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("portal stop = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("portal stop timeout")
|
||||
}
|
||||
|
||||
if got := ticketCalls.Load(); got != 2 {
|
||||
t.Fatalf("ticket calls = %d, want 2", got)
|
||||
}
|
||||
if got := refreshCalls.Load(); got != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", got)
|
||||
}
|
||||
if got, _ := rejectedSeen.Load().(string); got != "stale-token" {
|
||||
t.Fatalf("rejected token = %q, want %q", got, "stale-token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain
|
||||
// retry sends the token returned by ForceRefreshToken instead of re-invoking
|
||||
// the provider (which could still serve the stale token).
|
||||
func TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) {
|
||||
providerCalls := 0
|
||||
var attemptTokens []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
token := req.Header.Get("x-user-access-token")
|
||||
attemptTokens = append(attemptTokens, token)
|
||||
if token != "rotated" {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://x","ticket":"t"}`)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
ticket, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://x",
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
providerCalls++
|
||||
return "stale", nil
|
||||
},
|
||||
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
|
||||
if rejectedToken != "stale" {
|
||||
t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale")
|
||||
}
|
||||
return "rotated", nil
|
||||
},
|
||||
SourceID: "s",
|
||||
HTTPClient: client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("requestPortalTicket = %v", err)
|
||||
}
|
||||
if ticket.Endpoint != "wss://x" || ticket.Ticket != "t" {
|
||||
t.Fatalf("ticket = %#v", ticket)
|
||||
}
|
||||
if providerCalls != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", providerCalls)
|
||||
}
|
||||
if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" {
|
||||
t.Fatalf("attempt tokens = %v", attemptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing
|
||||
// refresh neither retries nor drops the refresh error or the original 401.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) {
|
||||
refreshErr := errors.New("refresh_token exchange failed")
|
||||
attempts := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://x",
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
return "", refreshErr
|
||||
},
|
||||
SourceID: "s",
|
||||
HTTPClient: client,
|
||||
})
|
||||
if !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("error should wrap refresh error, got %v", err)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
|
||||
t.Fatalf("error should keep original 401, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1 (no retry after failed refresh)", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward
|
||||
// compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) {
|
||||
attempts := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://x", AccessToken: "stale", SourceID: "s", HTTPClient: client,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
|
||||
t.Fatalf("fatal 401 expected, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal guards against refresh loops: the
|
||||
// controlled retry happens exactly once even if the rotated token is also
|
||||
// rejected.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal(t *testing.T) {
|
||||
attempts := 0
|
||||
refreshCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://x",
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
refreshCalls++
|
||||
return "rotated-but-still-rejected", nil
|
||||
},
|
||||
SourceID: "s",
|
||||
HTTPClient: client,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
|
||||
t.Fatalf("fatal 401 expected after single retry, got %v", err)
|
||||
}
|
||||
if attempts != 2 || refreshCalls != 1 {
|
||||
t.Fatalf("attempts = %d refreshCalls = %d, want 2/1", attempts, refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated
|
||||
// token is rejected instead of being sent to the server.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) {
|
||||
attempts := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://x",
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
return " ", nil
|
||||
},
|
||||
SourceID: "s",
|
||||
HTTPClient: client,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("empty rotated token error expected, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry mirrors the portal behavior for the
|
||||
// personal stream ticket path.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry(t *testing.T) {
|
||||
var attemptTokens []string
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: func(context.Context) (string, error) { return "stale", nil },
|
||||
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
|
||||
if rejectedToken != "stale" {
|
||||
t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale")
|
||||
}
|
||||
return "rotated", nil
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
token := req.Header.Get("x-user-access-token")
|
||||
attemptTokens = append(attemptTokens, token)
|
||||
if token != "rotated" {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ticket, err := src.fetchTicket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTicket = %v", err)
|
||||
}
|
||||
if ticket.Endpoint != "wss://stream.test" || ticket.Ticket != "ticket" {
|
||||
t.Fatalf("ticket = %#v", ticket)
|
||||
}
|
||||
if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" {
|
||||
t.Fatalf("attempt tokens = %v", attemptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh
|
||||
// keeps the 401 fatal (not retryable) and wraps the refresh error.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) {
|
||||
refreshErr := errors.New("refresh_token exchange failed")
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
return "", refreshErr
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = src.fetchTicket(context.Background())
|
||||
if !errors.Is(err, refreshErr) {
|
||||
t.Fatalf("error should wrap refresh error, got %v", err)
|
||||
}
|
||||
if isRetryablePersonalError(err) {
|
||||
t.Fatalf("failed refresh should stay fatal, got retryable %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// brokenBody simulates a response body that fails mid-read, e.g. the server
|
||||
// closing the connection before the error payload is fully written.
|
||||
type brokenBody struct{}
|
||||
|
||||
func (brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (brokenBody) Close() error { return nil }
|
||||
|
||||
func TestCrossPlatformCoveragePortalTicketNon2xxTruncatedBodyKeepsStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
retryable bool
|
||||
}{
|
||||
{status: http.StatusUnauthorized},
|
||||
{status: http.StatusTooManyRequests, retryable: true},
|
||||
{status: http.StatusServiceUnavailable, retryable: true},
|
||||
} {
|
||||
t.Run(http.StatusText(tc.status), func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: tc.status, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.test",
|
||||
SourceID: "source",
|
||||
}, client, "token")
|
||||
if status != tc.status || err == nil || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", tc.status)) {
|
||||
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want HTTP %d", status, err, tc.status)
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("non-2xx status error should not expose diagnostic body read failure: %v", err)
|
||||
}
|
||||
var stageErr *portalStageError
|
||||
if tc.retryable {
|
||||
if !errors.As(err, &stageErr) || stageErr.status != tc.status || !stageErr.retryable {
|
||||
t.Fatalf("retryable status should return retryable portalStageError, got %T %v", err, err)
|
||||
}
|
||||
} else if errors.As(err, &stageErr) && stageErr.retryable {
|
||||
t.Fatalf("fatal status should not become retryable stage error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalTicket200TruncatedBodyIsRetryable(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.test",
|
||||
SourceID: "source",
|
||||
}, client, "token")
|
||||
if status != http.StatusOK || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want 200 + io.ErrUnexpectedEOF", status, err)
|
||||
}
|
||||
var stageErr *portalStageError
|
||||
if !errors.As(err, &stageErr) || !stageErr.retryable || stageErr.stage != "ticket_request" {
|
||||
t.Fatalf("2xx truncated body should be retryable ticket_request stage, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal guards the single
|
||||
// refresh-retry protection: a 401 whose body fails with unexpected EOF must
|
||||
// be classified by status (fatal) and never wrapped as retryable, otherwise
|
||||
// the outer reconnect loop would refresh again on every iteration.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) {
|
||||
attempts := 0
|
||||
refreshCalls := 0
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
refreshCalls++
|
||||
return "rotated", nil
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = src.fetchTicket(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
|
||||
t.Fatalf("fatal 401 error expected, got %v", err)
|
||||
}
|
||||
if isRetryablePersonalError(err) {
|
||||
t.Fatalf("401 with truncated body must stay fatal, got retryable %v", err)
|
||||
}
|
||||
if refreshCalls != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing
|
||||
// behavior for success responses: a body read failure on 2xx is a transient
|
||||
// transport problem and remains retryable.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) {
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = src.fetchTicket(context.Background())
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("error should wrap io.ErrUnexpectedEOF, got %v", err)
|
||||
}
|
||||
if !isRetryablePersonalError(err) {
|
||||
t.Fatalf("2xx body read failure should stay retryable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges covers the
|
||||
// remaining fetchTicketAttempt branches: transport failures and retryable
|
||||
// statuses stay retryable, while a well-formed response missing the endpoint
|
||||
// or ticket fields stays fatal.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges(t *testing.T) {
|
||||
newSource := func(rt roundTripFunc) *PersonalSource {
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: rt},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
dialErr := errors.New("dial tcp: connection refused")
|
||||
src := newSource(func(*http.Request) (*http.Response, error) { return nil, dialErr })
|
||||
_, _, err := src.fetchTicketAttempt(context.Background(), "token")
|
||||
if !errors.Is(err, dialErr) || !isRetryablePersonalError(err) {
|
||||
t.Fatalf("transport failure should stay retryable and wrap cause, got %v", err)
|
||||
}
|
||||
|
||||
src = newSource(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("busy")), Header: make(http.Header)}, nil
|
||||
})
|
||||
_, status, err := src.fetchTicketAttempt(context.Background(), "token")
|
||||
if status != http.StatusServiceUnavailable || !isRetryablePersonalError(err) {
|
||||
t.Fatalf("503 should stay retryable, got status %d err %v", status, err)
|
||||
}
|
||||
|
||||
src = newSource(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"","ticket":""}`)), Header: make(http.Header)}, nil
|
||||
})
|
||||
_, _, err = src.fetchTicketAttempt(context.Background(), "token")
|
||||
if err == nil || isRetryablePersonalError(err) || !strings.Contains(err.Error(), "missing endpoint or ticket") {
|
||||
t.Fatalf("missing ticket fields should stay fatal, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type tokenProviderRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalSourceResolvesTokenForEveryTicketRequest(t *testing.T) {
|
||||
tokens := []string{"token-a", "token-b"}
|
||||
calls := 0
|
||||
source, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
token := tokens[calls]
|
||||
calls++
|
||||
return token, nil
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
want := tokens[calls-1]
|
||||
if got := req.Header.Get("x-user-access-token"); got != want {
|
||||
t.Fatalf("token header = %q, want %q", got, want)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if _, err := source.fetchTicket(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalTicketProviderFailureStopsBeforeHTTP(t *testing.T) {
|
||||
want := errors.New("token store failed")
|
||||
httpCalled := false
|
||||
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.test",
|
||||
AccessTokenProvider: func(context.Context) (string, error) { return "", want },
|
||||
SourceID: "source",
|
||||
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||
httpCalled = true
|
||||
return nil, errors.New("unexpected HTTP")
|
||||
})},
|
||||
})
|
||||
if !errors.Is(err, want) || httpCalled {
|
||||
t.Fatalf("request error = %v, httpCalled=%v", err, httpCalled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
)
|
||||
|
||||
func transientRefreshError() error {
|
||||
return &authpkg.HTTPStatusError{StatusCode: http.StatusServiceUnavailable}
|
||||
}
|
||||
|
||||
func terminalRefreshError() error {
|
||||
return &authpkg.HTTPStatusError{StatusCode: http.StatusUnauthorized}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalRetryLogErrorReportsOnlySafeTransientStatus(t *testing.T) {
|
||||
cause := errors.New("personal source: ticket HTTP 401 secret detail")
|
||||
_, err := refreshRejectedSourceToken(context.Background(), func(context.Context, string) (string, error) {
|
||||
return "", transientRefreshError()
|
||||
}, "rejected", "personal source", cause)
|
||||
if got, want := personalRetryLogError(retryPersonal(err)), "personal source: token refresh HTTP 503"; got != want {
|
||||
t.Fatalf("personalRetryLogError() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalSourceRetriesTransientTokenResolutionFailure(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var calls atomic.Int32
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
if calls.Add(1) == 2 {
|
||||
cancel()
|
||||
}
|
||||
return "", transientRefreshError()
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
TicketURL: "https://ticket.invalid",
|
||||
ReconnectMin: time.Millisecond,
|
||||
ReconnectMax: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start() error = %v, want context canceled after retry", err)
|
||||
}
|
||||
if calls.Load() != 2 || src.State().ReconnectCount != 1 {
|
||||
t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
calls.Add(1)
|
||||
return "", terminalRefreshError()
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
TicketURL: "https://ticket.invalid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
|
||||
if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal {
|
||||
t.Fatalf("Start() error = %v, want terminal refresh failure", err)
|
||||
}
|
||||
if calls.Load() != 1 || src.State().ReconnectCount != 0 {
|
||||
t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var ticketCalls atomic.Int32
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ticketCalls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil },
|
||||
ForceRefreshToken: func(_ context.Context, rejected string) (string, error) {
|
||||
if rejected != "old-token" {
|
||||
t.Fatalf("rejected token = %q, want old-token", rejected)
|
||||
}
|
||||
if refreshCalls.Add(1) == 2 {
|
||||
cancel()
|
||||
}
|
||||
return "", transientRefreshError()
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
TicketURL: srv.URL,
|
||||
HTTPClient: srv.Client(),
|
||||
ReconnectMin: time.Millisecond,
|
||||
ReconnectMax: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start() error = %v, want context canceled after retry", err)
|
||||
}
|
||||
if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 {
|
||||
t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalRetryLogErrorFallsBackToNetworkMessage(t *testing.T) {
|
||||
err := retryPersonal(fmt.Errorf("personal source: resolve access token: %w", errors.New("dial tcp: lookup oauth.invalid")))
|
||||
if got, want := personalRetryLogError(err), "personal source: token refresh: temporary network error"; got != want {
|
||||
t.Fatalf("personalRetryLogError() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalStageErrorNilAndUnwrap(t *testing.T) {
|
||||
var nilErr *portalStageError
|
||||
if got, want := nilErr.Error(), "source: portal stream failed"; got != want {
|
||||
t.Fatalf("nil stage error = %q, want %q", got, want)
|
||||
}
|
||||
if nilErr.Unwrap() != nil {
|
||||
t.Fatal("nil stage error should unwrap to nil")
|
||||
}
|
||||
cause := errors.New("cause")
|
||||
stageErr := &portalStageError{stage: "stream_read", retryable: true, cause: cause}
|
||||
if !errors.Is(stageErr, cause) {
|
||||
t.Fatalf("stage error should unwrap to cause: %v", stageErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var calls atomic.Int32
|
||||
src, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.invalid",
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
if calls.Add(1) == 2 {
|
||||
cancel()
|
||||
}
|
||||
return "", transientRefreshError()
|
||||
},
|
||||
SourceID: "open",
|
||||
// Min above max exercises the reconnect clamp.
|
||||
ReconnectMin: 2 * time.Millisecond,
|
||||
ReconnectMax: time.Millisecond,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start() error = %v, want context canceled after retry", err)
|
||||
}
|
||||
if calls.Load() != 2 || src.State().ReconnectCount != 1 {
|
||||
t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalSourceResetsBackoffAfterAckedAttempt(t *testing.T) {
|
||||
var ticketCalls atomic.Int32
|
||||
upgrader := websocket.Upgrader{}
|
||||
var wsURL string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if ticketCalls.Add(1) > 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsURL)+`,"ticket":"t"}`)
|
||||
})
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "m"}, Data: `{}`}
|
||||
_ = conn.WriteJSON(df)
|
||||
// Wait for the ACK, then close so the read fails retryably with an
|
||||
// acked attempt behind it, which resets the reconnect backoff.
|
||||
_, _, _ = conn.ReadMessage()
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
||||
|
||||
src, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: srv.URL + "/ticket",
|
||||
AccessToken: "t",
|
||||
SourceID: "open",
|
||||
HTTPClient: srv.Client(),
|
||||
ReconnectMin: time.Millisecond,
|
||||
ReconnectMax: time.Millisecond,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var events atomic.Int32
|
||||
err = src.Start(context.Background(), func(*dwsevent.RawEvent) { events.Add(1) })
|
||||
if err == nil || !strings.Contains(err.Error(), "portal ticket HTTP 400") {
|
||||
t.Fatalf("Start() error = %v, want fatal ticket HTTP 400 after reconnect", err)
|
||||
}
|
||||
if events.Load() != 1 || ticketCalls.Load() != 2 || src.State().ReconnectCount != 1 {
|
||||
t.Fatalf("events=%d ticket calls=%d reconnects=%d, want 1/2/1", events.Load(), ticketCalls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
src, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.invalid",
|
||||
AccessTokenProvider: func(context.Context) (string, error) {
|
||||
calls.Add(1)
|
||||
return "", terminalRefreshError()
|
||||
},
|
||||
SourceID: "open",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
|
||||
if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal {
|
||||
t.Fatalf("Start() error = %v, want terminal refresh failure", err)
|
||||
}
|
||||
if calls.Load() != 1 || src.State().ReconnectCount != 0 {
|
||||
t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var ticketCalls atomic.Int32
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ticketCalls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src, err := New(Config{PortalTicket: &PortalTicketConfig{
|
||||
TicketURL: srv.URL,
|
||||
AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil },
|
||||
ForceRefreshToken: func(_ context.Context, rejected string) (string, error) {
|
||||
if rejected != "old-token" {
|
||||
t.Fatalf("rejected token = %q, want old-token", rejected)
|
||||
}
|
||||
if refreshCalls.Add(1) == 2 {
|
||||
cancel()
|
||||
}
|
||||
return "", transientRefreshError()
|
||||
},
|
||||
SourceID: "open",
|
||||
HTTPClient: srv.Client(),
|
||||
ReconnectMin: time.Millisecond,
|
||||
ReconnectMax: time.Millisecond,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start() error = %v, want context canceled after retry", err)
|
||||
}
|
||||
if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 {
|
||||
t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ const (
|
||||
// parallel. When empty, the platform default applies.
|
||||
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
||||
|
||||
// TestNamespaceEnv isolates the Windows HKCU registry backend for tests.
|
||||
// Production code must not set it. Other platforms already isolate secure
|
||||
// storage through StorageDirEnv and ignore this value.
|
||||
TestNamespaceEnv = "DWS_KEYCHAIN_TEST_NAMESPACE"
|
||||
|
||||
// DisableKeychainEnv opts the macOS implementation out of system
|
||||
// Keychain access for the DEK, falling back to a file-based DEK
|
||||
// (same scheme as Linux). Intended for sandboxed runtimes where
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user