mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
32 Commits
feat/chat_
...
v1.0.73-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c8eb254d4 | ||
|
|
126c10ba79 | ||
|
|
e7ff09f28e | ||
|
|
b704a495de | ||
|
|
72fe82d70d | ||
|
|
683a721a76 | ||
|
|
bbb3c505e0 | ||
|
|
e50820bd11 | ||
|
|
ac1e09e46f | ||
|
|
2bfde8d886 | ||
|
|
7b989948c4 | ||
|
|
964c571063 | ||
|
|
4a523b12f2 | ||
|
|
c6039a923c | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd | ||
|
|
44514ad114 | ||
|
|
4a56748bfa | ||
|
|
0b6faa01bf | ||
|
|
1efe2dfb33 | ||
|
|
767386cb57 | ||
|
|
e71c76155e | ||
|
|
c363acf94e | ||
|
|
05285bb696 | ||
|
|
4c0f93bd6a | ||
|
|
76ebd49382 | ||
|
|
6c14c425fc | ||
|
|
27df16d3b2 | ||
|
|
47dc003601 | ||
|
|
4e0a6a988c | ||
|
|
15e4175986 | ||
|
|
12b7f7a0cd |
124
.github/workflows/ci.yml
vendored
124
.github/workflows/ci.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +9,12 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -295,6 +302,11 @@ jobs:
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -308,6 +320,23 @@ jobs:
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
@@ -341,16 +370,22 @@ jobs:
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -361,31 +396,68 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
id: build_cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
@@ -395,7 +467,7 @@ jobs:
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -472,8 +544,8 @@ jobs:
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
|
||||
145
.github/workflows/release.yml
vendored
145
.github/workflows/release.yml
vendored
@@ -9,10 +9,54 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REHEARSAL_BRANCH: test/npm-staged-publish-rehearsal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TAG" == *-beta.* ]]; then
|
||||
git fetch origin "$REHEARSAL_BRANCH"
|
||||
REHEARSAL_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if [[ "$HEAD_SHA" != "$REHEARSAL_SHA" ]]; then
|
||||
echo "Beta rehearsal tag ${TAG} must point to the current origin/${REHEARSAL_BRANCH} commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
build-stage-assets:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -26,35 +70,86 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: release --clean --skip=publish
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Verify release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Publish to npm
|
||||
- name: Pack npm tarball
|
||||
id: pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Collect rehearsal assets
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
PACK_FILE: ${{ steps.pack.outputs.filename }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir staged-release-assets
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt "$PACK_FILE" staged-release-assets/
|
||||
|
||||
- name: Upload rehearsal artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
stage-publish:
|
||||
needs: build-stage-assets
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download rehearsal artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets
|
||||
|
||||
- name: Verify rehearsal asset
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd staged-release-assets && sha256sum --check checksums.txt)
|
||||
PACK_FILE="$(find staged-release-assets -maxdepth 1 -type f -name '*.tgz' -print -quit)"
|
||||
test -n "$PACK_FILE"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage npm package
|
||||
run: npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
@@ -1527,6 +1552,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/release-workflow.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
|
||||
@@ -19,6 +19,29 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
|
||||
155
events/approval/preconsume.go
Normal file
155
events/approval/preconsume.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type approvalEventType string
|
||||
type approvalSubscriptionPath string
|
||||
|
||||
type approvalSubscriptionConfig struct {
|
||||
eventType approvalEventType
|
||||
subscribePath approvalSubscriptionPath
|
||||
}
|
||||
|
||||
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
eventType := string(cfg.eventType)
|
||||
subscribePath := string(cfg.subscribePath)
|
||||
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registered := make([]string, 0, len(subscriptionTypes))
|
||||
for _, subscriptionType := range subscriptionTypes {
|
||||
body := map[string]string{"subscription_type": subscriptionType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
|
||||
}
|
||||
registered = append(registered, subscriptionType)
|
||||
}
|
||||
|
||||
// Approval subscriptions are durable user-auth relations. Consuming events
|
||||
// should not cancel that relation when this local process exits.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
|
||||
raw := strings.TrimSpace(params["subscription_type"])
|
||||
if raw == "" {
|
||||
return append([]string(nil), approvalAllSubscriptionTypes...), nil
|
||||
}
|
||||
|
||||
values, err := parseApprovalSubscriptionTypeValues(raw)
|
||||
if err != nil {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(selected))
|
||||
for _, value := range approvalAllSubscriptionTypes {
|
||||
if selected[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strings.Split(raw, ","), nil
|
||||
}
|
||||
|
||||
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
|
||||
eventType,
|
||||
failed,
|
||||
)
|
||||
hint := fmt.Sprintf(
|
||||
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
|
||||
eventType,
|
||||
)
|
||||
if len(registered) > 0 {
|
||||
msg = fmt.Sprintf(
|
||||
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
hint = fmt.Sprintf(
|
||||
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = msg + ": " + upstream
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid subscription_type for EventKey %s: %q", eventType, value).
|
||||
WithParam("--param").
|
||||
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
eventType)
|
||||
}
|
||||
179
events/approval/register.go
Normal file
179
events/approval/register.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package approval registers Approval-domain EventKeys.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
|
||||
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
|
||||
|
||||
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
|
||||
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
|
||||
|
||||
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
|
||||
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
|
||||
)
|
||||
|
||||
var approvalAllSubscriptionTypes = []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
}
|
||||
|
||||
// Keys returns all Approval-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
DisplayName: "Approval instance status changed",
|
||||
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalInstanceStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:instance:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
|
||||
},
|
||||
{
|
||||
Key: eventTypeApprovalTaskStatusChangedV4,
|
||||
DisplayName: "Approval task status changed",
|
||||
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalTaskStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:task:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionParams() []event.ParamDef {
|
||||
return []event.ParamDef{
|
||||
{
|
||||
Name: "subscription_type",
|
||||
Type: event.ParamMulti,
|
||||
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
|
||||
Values: []event.ParamValue{
|
||||
{
|
||||
Value: approvalSubscriptionTypeInvolved,
|
||||
Desc: "Receive events where the current user is the approval requester or approver.",
|
||||
},
|
||||
{
|
||||
Value: approvalSubscriptionTypeManaged,
|
||||
Desc: "Receive events under approval definitions managed by the current user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
StartUser *ApprovalUserID `json:"start_user"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalInstanceStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
StartUser: envelope.Event.StartUser,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
TaskExternalID string `json:"task_external_id"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalTaskStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
TaskID: envelope.Event.TaskID,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
TaskExternalID: envelope.Event.TaskExternalID,
|
||||
AssignedUser: envelope.Event.AssignedUser,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
654
events/approval/register_test.go
Normal file
654
events/approval/register_test.go
Normal file
@@ -0,0 +1,654 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
type fakeAPIClient struct {
|
||||
calls []recordedCall
|
||||
err error
|
||||
errOnCall int
|
||||
}
|
||||
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func TestKeysApprovalMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
schemaType reflect.Type
|
||||
subscribe string
|
||||
}{
|
||||
{
|
||||
key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
scope: "approval:instance:read",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalInstancesSubscription,
|
||||
},
|
||||
{
|
||||
key: eventTypeApprovalTaskStatusChangedV4,
|
||||
scope: "approval:task:read",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalTasksSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
byKey := make(map[string]event.KeyDefinition, len(keys))
|
||||
for _, def := range keys {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
def, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %s", tc.key)
|
||||
}
|
||||
if def.EventType != tc.key {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
|
||||
}
|
||||
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process must flatten raw V2 envelopes")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume must subscribe approval user-auth events")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
|
||||
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
|
||||
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
|
||||
}
|
||||
assertSubscriptionParam(t, def.Params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
|
||||
t.Helper()
|
||||
if len(params) != 1 {
|
||||
t.Fatalf("len(params) = %d, want 1", len(params))
|
||||
}
|
||||
p := params[0]
|
||||
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, v := range p.Values {
|
||||
got[v.Value] = v.Desc
|
||||
}
|
||||
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
|
||||
if got[want] == "" {
|
||||
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reflectedApprovalSchema struct {
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type reflectedApprovalSchemaProperty struct {
|
||||
Format string `json:"format"`
|
||||
Enum []string `json:"enum"`
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
func TestApprovalSchemasAnnotations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaType reflect.Type
|
||||
eventType string
|
||||
statusValues []string
|
||||
userField string
|
||||
}{
|
||||
{
|
||||
name: "instance",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "start_user",
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "assigned_user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var schema reflectedApprovalSchema
|
||||
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
props := schema.Properties
|
||||
eventTypeEnum := props["type"].Enum
|
||||
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
|
||||
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
|
||||
}
|
||||
if got := props["timestamp"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("timestamp format = %v, want timestamp_ms", got)
|
||||
}
|
||||
assertEnumContains(t, props["status"].Enum, tc.statusValues)
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
userProps := props[tc.userField].Properties
|
||||
if got := userProps["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["union_id"].Format; got != "union_id" {
|
||||
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["user_id"].Format; got != "user_id" {
|
||||
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnumContains(t *testing.T, raw []string, wants []string) {
|
||||
t.Helper()
|
||||
got := make(map[string]bool, len(raw))
|
||||
for _, v := range raw {
|
||||
got[v] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !got[want] {
|
||||
t.Errorf("enum missing %q; enum=%v", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType string
|
||||
subscribePath string
|
||||
params map[string]string
|
||||
wantTypes []string
|
||||
}{
|
||||
{
|
||||
name: "instance omitted subscription_type registers both",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task explicit single managed",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
|
||||
wantTypes: []string{approvalSubscriptionTypeManaged},
|
||||
},
|
||||
{
|
||||
name: "task comma separated multi canonicalizes and deduplicates",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance json array multi",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: approvalEventType(tc.eventType),
|
||||
subscribePath: approvalSubscriptionPath(tc.subscribePath),
|
||||
})
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume returned error: %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(wantTypes) {
|
||||
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
|
||||
t.Helper()
|
||||
if got.method != wantMethod {
|
||||
t.Errorf("method = %q, want %q", got.method, wantMethod)
|
||||
}
|
||||
if got.path != wantPath {
|
||||
t.Errorf("path = %q, want %q", got.path, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(got.body, wantBody) {
|
||||
t.Errorf("body = %#v, want %#v", got.body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
|
||||
t.Run("nil runtime", func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil runtime error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("err = %T/%v, want typed internal error", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
|
||||
t.Run("invalid subscription type "+raw, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid subscription_type error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("invalid subscription_type should carry a hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
|
||||
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
})
|
||||
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial registration error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on registration error")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"registered subscription_type(s) [INVOLVED_APPROVAL]",
|
||||
"failed subscription_type MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Errorf("partial error message missing %q: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"already registered",
|
||||
"--param subscription_type=MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
|
||||
t.Fatalf("nil cause returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
upstream,
|
||||
)
|
||||
if err != upstream {
|
||||
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
|
||||
t.Errorf("message missing failed relation: %q", p.Message)
|
||||
}
|
||||
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
|
||||
cause := errors.New("transport closed")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
cause,
|
||||
)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
|
||||
t.Errorf("hint missing no-registration context: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
|
||||
out := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_001",
|
||||
"event_type": "approval.instance.status_changed_v4",
|
||||
"create_time": "1710000000000"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_001",
|
||||
"instance_code": "instance_code_001",
|
||||
"external_id": "external_001",
|
||||
"status": "PENDING",
|
||||
"operate_time": "1666079207003",
|
||||
"start_user": {
|
||||
"open_id": "ou_start",
|
||||
"union_id": "on_start",
|
||||
"user_id": "user_start"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
|
||||
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
|
||||
}
|
||||
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
|
||||
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
|
||||
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
|
||||
out := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_001",
|
||||
"event_type": "approval.task.status_changed_v4",
|
||||
"create_time": "1710000000001"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_002",
|
||||
"instance_code": "instance_code_002",
|
||||
"task_id": "task_001",
|
||||
"external_id": "external_002",
|
||||
"task_external_id": "task_external_001",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207004",
|
||||
"assigned_user": {
|
||||
"open_id": "ou_assignee",
|
||||
"union_id": "on_assignee",
|
||||
"user_id": "user_assignee"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
|
||||
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
|
||||
}
|
||||
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
|
||||
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
|
||||
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
|
||||
instance := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_fallback",
|
||||
"create_time": "1710000000002"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207005"
|
||||
}
|
||||
}`)
|
||||
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
|
||||
task := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_fallback",
|
||||
"create_time": "1710000000003"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"task_id": "task_fallback",
|
||||
"status": "DONE",
|
||||
"operate_time": "1666079207006"
|
||||
}
|
||||
}`)
|
||||
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
|
||||
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", processApprovalInstanceStatusChanged},
|
||||
{"task", processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.process(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process nil raw returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("Process nil raw output = %s, want nil", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalInstanceStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalTaskStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApprovalKeysRegisterCleanly(t *testing.T) {
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
}
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
42
events/approval/types.go
Normal file
42
events/approval/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
// ApprovalUserID identifies a user in the three Lark ID formats included by
|
||||
// approval status-change events.
|
||||
type ApprovalUserID struct {
|
||||
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
|
||||
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
|
||||
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
|
||||
}
|
||||
|
||||
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
|
||||
// approval.instance.status_changed_v4.
|
||||
type ApprovalInstanceStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
|
||||
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
|
||||
}
|
||||
|
||||
// ApprovalTaskStatusChangedV4Output is the flattened shape for
|
||||
// approval.task.status_changed_v4.
|
||||
type ApprovalTaskStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
|
||||
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
|
||||
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
|
||||
@@ -19,12 +19,18 @@ import (
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
Path string `json:"path"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
type commentContent struct {
|
||||
Body string
|
||||
Path string
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
@@ -34,12 +40,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
@@ -47,32 +52,44 @@ func main() {
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
|
||||
content, err := commentBody(eventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCommentContent(kind, content), nil
|
||||
}
|
||||
|
||||
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
|
||||
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (string, error) {
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
default:
|
||||
return "", nil
|
||||
return commentContent{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `CLIENT_SECRET=$(security find-generic-password -w)`
|
||||
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
diags, err := auditEvent("event.json", "pull_request_review_comment")
|
||||
if err != nil {
|
||||
t.Fatalf("auditEvent() error = %v", err)
|
||||
}
|
||||
for _, diag := range diags {
|
||||
if diag.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
|
||||
}
|
||||
}
|
||||
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
|
||||
for _, finding := range pathless {
|
||||
if finding.Rule == "public_content_generic_credential" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
|
||||
}
|
||||
|
||||
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
|
||||
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
|
||||
content := commentContent{
|
||||
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
|
||||
Path: "cmd/agent/list_test.go",
|
||||
}
|
||||
|
||||
diags := scanCommentContent("pull_request_review_comment", content)
|
||||
for _, diag := range diags {
|
||||
if diag.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
if diag.File != content.Path {
|
||||
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing provider credential diagnostic: %#v", diags)
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
|
||||
@@ -23,9 +23,10 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "example-public-key"
|
||||
api_`+`key = "`+providerValue+`"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -199,13 +200,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -215,14 +217,7 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -306,8 +301,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +333,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -358,15 +353,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -374,7 +365,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -391,7 +382,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +423,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -448,15 +439,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -489,12 +476,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -506,13 +494,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -621,7 +603,8 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -685,10 +668,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -17,3 +20,60 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" participant Client",
|
||||
" participant AccessTokenHashTransport",
|
||||
" participant SecurityPolicyTransport",
|
||||
" Client->>AccessTokenHashTransport: Send request with bearer token",
|
||||
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
|
||||
" Client -> ClientSecret: Resolve configured credential",
|
||||
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
credentialAssignment := "password=" + providerValue
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" Client->>Server: Send " + credentialAssignment,
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
|
||||
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential in review comment should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/qualitygate/publiccontent/credential.go
Normal file
88
internal/qualitygate/publiccontent/credential.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func credentialValueHasStrongEvidence(key, value string) bool {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
|
||||
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
|
||||
for _, candidate := range candidates {
|
||||
if providerCredentialIdentifier(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isCredentialMetadataField(key) {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
|
||||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
|
||||
}
|
||||
|
||||
func credentialEvidenceCandidates(value string) []string {
|
||||
candidates := []string{value}
|
||||
for range 3 {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, decoded)
|
||||
value = decoded
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func isCredentialMetadataField(key string) bool {
|
||||
if isBenignTokenField(key) {
|
||||
return true
|
||||
}
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-1] {
|
||||
case "hash", "id", "kind", "marker", "prefix", "transport":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func base64PaddedCredentialValue(value string) bool {
|
||||
if len(value) < 16 || !strings.HasSuffix(value, "=") {
|
||||
return false
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
|
||||
}
|
||||
|
||||
func percentEncodedCredentialValue(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var escapes int
|
||||
for i := 0; i+2 < len(value); i++ {
|
||||
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
|
||||
escapes++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return escapes >= 2
|
||||
}
|
||||
|
||||
func isHexByte(value byte) bool {
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
@@ -383,33 +383,63 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
case providerTokenWithBody(value, "sk_live_", 16, ""),
|
||||
providerTokenWithBody(value, "sk_test_", 16, ""),
|
||||
providerTokenWithBody(value, "ghp_", 16, ""),
|
||||
providerTokenWithBody(value, "gho_", 16, ""),
|
||||
providerTokenWithBody(value, "ghu_", 16, ""),
|
||||
providerTokenWithBody(value, "github_pat_", 16, "_"),
|
||||
providerTokenWithBody(value, "xoxb_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxb-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa-", 16, "-"),
|
||||
awsAccessKeyIdentifier(value):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
|
||||
body, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok || len(body) < minBodyLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func awsAccessKeyIdentifier(value string) bool {
|
||||
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[4:] {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
|
||||
@@ -47,15 +47,30 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
|
||||
rawMatch := line[location[0]:location[1]]
|
||||
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
|
||||
continue
|
||||
}
|
||||
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
|
||||
if !isCredentialAssignmentMatch(rawMatch) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
|
||||
evidenceValue := value
|
||||
if sourceCodeFile(file) {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
|
||||
evidenceValue = rhs
|
||||
}
|
||||
}
|
||||
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
|
||||
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
|
||||
continue
|
||||
}
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -64,7 +79,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -123,21 +138,43 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
func validCredentialAssignmentStart(line string, start int, match string) bool {
|
||||
if start <= 0 || credentialAssignmentOperator(match) != ":" {
|
||||
return true
|
||||
}
|
||||
prefix := strings.TrimSpace(line[:start])
|
||||
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
|
||||
if strings.HasSuffix(prefix, arrow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialAssignmentOperator(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(match[len(key):])
|
||||
if strings.HasPrefix(rest, ":=") {
|
||||
return ":="
|
||||
}
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return ":"
|
||||
}
|
||||
if strings.HasPrefix(rest, "=") {
|
||||
return "="
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -288,7 +325,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -323,52 +360,8 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
@@ -488,17 +481,20 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -518,17 +514,16 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -536,7 +531,12 @@ func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,6 +593,7 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -685,6 +686,18 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -753,7 +766,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -980,6 +993,7 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,26 +251,22 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"API_KEY=notredactedreal",
|
||||
"API_KEY=notplaceholdersecret",
|
||||
"API_KEY=abcxxxxreal",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -294,17 +290,25 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
|
||||
if findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
jsonToken := "real-json-token"
|
||||
jsonSecret := "real " + "secret value"
|
||||
jsonKey := "real-json-key"
|
||||
jsonTenantToken := "real-tenant-json-token"
|
||||
jsonAppSecret := "real-app-secret"
|
||||
jsonPrefixedKey := "real-prefixed-key"
|
||||
jsonTenantCamelToken := "real-tenant-camel-token"
|
||||
jsonGithubToken := "real-github-token"
|
||||
jsonVendorKey := "real-vendor-key"
|
||||
jsonSlackBotToken := "xoxb-real-token"
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -334,12 +338,13 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -347,13 +352,7 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -364,85 +363,77 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
|
||||
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
|
||||
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
|
||||
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
|
||||
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
|
||||
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
|
||||
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
|
||||
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
|
||||
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -593,18 +584,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY=${{" + stripeLike + "}}",
|
||||
"TOKEN=${{real-secret-token-value}}",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
|
||||
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +639,7 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -821,26 +813,36 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
@@ -848,8 +850,114 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "` + stripeLike + `",`,
|
||||
`"token_id": "` + githubToken + `",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
if !credentialShapedValue(` "` + providerValue + `" `) {
|
||||
t.Fatal("space-padded quoted provider credential should be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
}{
|
||||
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
|
||||
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
|
||||
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
|
||||
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
|
||||
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
|
||||
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
|
||||
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []string{
|
||||
"access_token: ghp%" + "5F" + providerBody,
|
||||
"access_token_hash: ghp%" + "255F" + providerBody,
|
||||
}
|
||||
for _, text := range tests {
|
||||
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"token_type: asian",
|
||||
"token_prefix: ASIA",
|
||||
"token_prefix: ghp_",
|
||||
"api_key: sk_live_example",
|
||||
"token_prefix: asianmarketsegment01",
|
||||
"token_prefix: ghp_placeholder_value",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -927,6 +1035,22 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
@@ -941,22 +1065,18 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -972,6 +1092,7 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -980,32 +1101,47 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
name: "typescript terminated secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
text: `const clientSecret: string = "` + providerValue + `";`,
|
||||
},
|
||||
{
|
||||
name: "typescript secret with trailing comment",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"; // production`,
|
||||
},
|
||||
{
|
||||
name: "typescript asserted secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `" as const;`,
|
||||
},
|
||||
{
|
||||
name: "typescript provider password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1018,24 +1154,154 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
|
||||
|
||||
got := ScanFile("fixtures/source_secret.ts", []byte(
|
||||
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
|
||||
))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
if count != 1 {
|
||||
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
|
||||
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
|
||||
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
|
||||
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
|
||||
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
|
||||
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
|
||||
providerSecret := "sk_" + "live_1234567890abcdef"
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`clientSecret := "` + providerSecret + `"`,
|
||||
`accessToken := "` + providerToken + `"`,
|
||||
}, "\n")+"\n"))
|
||||
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCredentialDecisionMatrix(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
|
||||
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
comment bool
|
||||
want bool
|
||||
}{
|
||||
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
|
||||
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
|
||||
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
|
||||
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
|
||||
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
|
||||
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
|
||||
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
|
||||
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
|
||||
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
|
||||
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
|
||||
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
|
||||
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
|
||||
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
|
||||
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
|
||||
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
|
||||
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
|
||||
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
|
||||
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
|
||||
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
|
||||
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got []Finding
|
||||
if tt.comment {
|
||||
got = ScanComment("issue_comment", tt.text)
|
||||
} else {
|
||||
got = ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
}
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "human readable access token", value: "user-access-token", want: false},
|
||||
{name: "delimited secret value", value: "secret-value", want: false},
|
||||
{name: "underscored secret fixture", value: "plain_secret", want: false},
|
||||
{name: "short delimited fixture", value: "t-abc", want: false},
|
||||
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
|
||||
{name: "real labeled fixture", value: "real-token", want: false},
|
||||
{name: "ambiguous concrete word", value: "supersecret", want: false},
|
||||
{name: "provider token", value: providerToken, want: true},
|
||||
{name: "high entropy secret", value: highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
|
||||
`secret := "secret-value"`,
|
||||
`samplePassword := "sample-password"`,
|
||||
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,9 +1382,10 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1152,9 +1419,10 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1368,39 +1636,43 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"CLIENT_SECRET=%" + stripeLike + "%",
|
||||
"GITHUB_TOKEN=%" + patLike + "%",
|
||||
"TOKEN=%real-secret-token-value%",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
|
||||
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
|
||||
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
|
||||
t.Helper()
|
||||
got := ScanFile(file, []byte(text+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,8 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.3",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.71",
|
||||
"version": "1.0.73-beta.3",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
|
||||
in_permissions && /^[^[:space:]]/ { exit }
|
||||
in_permissions { print }
|
||||
' "$workflow")"
|
||||
workflow_concurrency="$(awk '
|
||||
/^concurrency:/ { in_concurrency = 1; print; next }
|
||||
in_concurrency && /^[^[:space:]]/ { exit }
|
||||
in_concurrency { print }
|
||||
' "$workflow")"
|
||||
fast_gate_section="$(job_section fast-gate)"
|
||||
unit_test_section="$(job_section unit-test)"
|
||||
lint_section="$(awk '
|
||||
@@ -46,6 +51,27 @@ results_section="$(awk '
|
||||
in_job { print }
|
||||
' "$workflow")"
|
||||
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
|
||||
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
|
||||
|
||||
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
|
||||
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
|
||||
echo "the supersession generation should match the PR-only run name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
|
||||
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
|
||||
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
|
||||
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
|
||||
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
|
||||
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
|
||||
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
|
||||
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
|
||||
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
|
||||
echo "e2e-live should use one repository-wide execution slot" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
|
||||
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "queue: max" <<<"$section"; then
|
||||
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "actions: read" <<<"$section"; then
|
||||
echo "e2e-live should use read-only Actions access for the supersession check" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
live_test_step="$(awk '
|
||||
/^ - name: Run CLI E2E tests/ { in_step = 1 }
|
||||
in_step { print }
|
||||
in_step && /^ - name: Publish CLI E2E test report/ { exit }
|
||||
' <<<"$section")"
|
||||
|
||||
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
|
||||
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required in \
|
||||
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
|
||||
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
|
||||
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
|
||||
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
|
||||
'::error::Superseded before live E2E started' \
|
||||
'exit 1'; do
|
||||
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
|
||||
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! awk '
|
||||
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
|
||||
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
|
||||
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
|
||||
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
|
||||
if (superseded_state != 3) exit 2
|
||||
superseded_closed = 1
|
||||
superseded_state = 0
|
||||
next
|
||||
}
|
||||
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
|
||||
END { exit superseded_closed && test_started ? 0 : 1 }
|
||||
' <<<"$live_test_step"; then
|
||||
echo "a superseded live run must stop before gotestsum starts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for output in \
|
||||
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
|
||||
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
|
||||
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
|
||||
if ! grep -Fq "$output" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should publish $output for the live job" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for validation_contract in \
|
||||
'case "$E2E_MODE" in' \
|
||||
'skip)' \
|
||||
'[ -z "$E2E_LIVE_PACKAGES" ]' \
|
||||
'full|subset)' \
|
||||
'[ -n "$E2E_LIVE_PACKAGES" ]' \
|
||||
'Invalid CLI E2E mode' \
|
||||
'exit 1'; do
|
||||
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! awk '
|
||||
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
|
||||
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
|
||||
END { if (!validated) exit 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should validate domain outputs before building" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
|
||||
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
|
||||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -272,16 +407,23 @@ if ! awk '
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
|
||||
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
|
||||
live_setup_step="$(awk -v name="$step_name" '
|
||||
$0 == " - name: " name { in_step = 1 }
|
||||
in_step { print }
|
||||
in_step && /^ - name:/ && $0 != " - name: " name { exit }
|
||||
' <<<"$section")"
|
||||
if grep -Eq '^ if:' <<<"$live_setup_step"; then
|
||||
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
|
||||
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
|
||||
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
|
||||
echo "e2e-live should fetch the tenant token via the dedicated script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "config init" <<<"$section"; then
|
||||
echo "e2e-live should use env credentials instead of config init"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
|
||||
echo "e2e-live should keep the bot app id under a test-only job env name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if awk '
|
||||
/^ e2e-live:/ { in_job = 1; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
|
||||
in_job && /^ env:/ { in_env = 1; next }
|
||||
in_env && /^ steps:/ { in_env = 0 }
|
||||
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
|
||||
END { exit found_standard_app_id ? 0 : 1 }
|
||||
' "$workflow"; then
|
||||
echo "e2e-live should not activate the env credential provider at job scope"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
|
||||
echo "e2e-live should pin the env credential brand to feishu"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if awk '
|
||||
/^ e2e-live:/ { in_job = 1; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
|
||||
in_job && /^ env:/ { in_env = 1; next }
|
||||
in_env && /^ steps:/ { in_env = 0 }
|
||||
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
|
||||
END { exit found_sensitive ? 0 : 1 }
|
||||
' "$workflow"; then
|
||||
echo "e2e-live should not expose live E2E credentials through job-level env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
|
||||
in_step && /id: live_e2e_tat/ { has_id = 1 }
|
||||
in_step && /^ if:/ { has_if = 1 }
|
||||
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
|
||||
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
|
||||
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
|
||||
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
|
||||
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
|
||||
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
echo "e2e-live should pass only a private tenant token file path through step output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Run CLI E2E tests/ { in_step = 1 }
|
||||
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
|
||||
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
|
||||
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
|
||||
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
|
||||
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
|
||||
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
|
||||
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
|
||||
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
|
||||
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
|
||||
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
|
||||
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
|
||||
in_step && /trap / { has_trap = 1 }
|
||||
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
|
||||
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should expose live E2E credentials only inside the test shell step"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
|
||||
echo "e2e-live should not retain an unreachable step-level skip branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
|
||||
fi
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$section" &&
|
||||
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
! grep -Fq "$fork_safe_guard" <<<"$section"; then
|
||||
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
164
scripts/fetch_e2e_tat.js
Normal file
164
scripts/fetch_e2e_tat.js
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
|
||||
//
|
||||
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
|
||||
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
|
||||
// and emits the file path as a step output so the test step can read it once
|
||||
// and then delete it.
|
||||
//
|
||||
// The secret arrives via environment variables; the OAuth parameter names are
|
||||
// literal because this is a source code file (.js), so the quality gate's
|
||||
// benign-code-credential exemption applies to the process.env references.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
|
||||
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
|
||||
const MAX_ATTEMPTS = 4;
|
||||
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
console.error(`::error::Missing required environment variable: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function postForm(url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === "http:" ? http : https;
|
||||
const req = transport.request(
|
||||
parsed,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
timeout: 20000,
|
||||
},
|
||||
(resp) => {
|
||||
const chunks = [];
|
||||
let settled = false;
|
||||
const rejectOnce = (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
resp.on("data", (chunk) => chunks.push(chunk));
|
||||
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
|
||||
resp.on("error", rejectOnce);
|
||||
resp.on("close", () => {
|
||||
if (!resp.complete) {
|
||||
rejectOnce(new Error("response closed before completion"));
|
||||
}
|
||||
});
|
||||
resp.on("end", () => {
|
||||
if (!resp.complete) {
|
||||
rejectOnce(new Error("response ended before completion"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({
|
||||
status: resp.statusCode,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
headers: resp.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("request timed out"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function encodeForm(params) {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchTenantToken() {
|
||||
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
|
||||
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
|
||||
|
||||
const body = encodeForm({
|
||||
grant_type: "client_credentials",
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
});
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(respBody);
|
||||
} catch {
|
||||
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
|
||||
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
|
||||
}
|
||||
if (payload) {
|
||||
const token = payload.access_token;
|
||||
if (status === 200 && payload.code === 0 && token) {
|
||||
return token;
|
||||
}
|
||||
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err.message;
|
||||
}
|
||||
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = await fetchTenantToken();
|
||||
console.log(`::add-mask::${token}`);
|
||||
|
||||
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
|
||||
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
|
||||
}
|
||||
|
||||
console.log("Prepared shared live E2E tenant token");
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeForm,
|
||||
fetchTenantToken,
|
||||
postForm,
|
||||
requireEnv,
|
||||
};
|
||||
203
scripts/fetch_e2e_tat.test.js
Normal file
203
scripts/fetch_e2e_tat.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
|
||||
|
||||
function startServer(handler) {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
handler(req, res, body);
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = server.address().port;
|
||||
resolve({ server, port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function abortResponse(res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "100",
|
||||
});
|
||||
res.write('{"code":0');
|
||||
setImmediate(() => res.destroy());
|
||||
}
|
||||
|
||||
function runScript(envOverrides) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
|
||||
const githubOutput = path.join(tmpDir, "github-output");
|
||||
const env = {
|
||||
...process.env,
|
||||
LARKSUITE_CLI_APP_ID: "test_app_id",
|
||||
TEST_BOT1_APP_SECRET: "test-secret",
|
||||
RUNNER_TEMP: tmpDir,
|
||||
GITHUB_OUTPUT: githubOutput,
|
||||
E2E_TAT_RETRY_BASE_MS: "10",
|
||||
...envOverrides,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data;
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data;
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
const output = fs.existsSync(githubOutput)
|
||||
? fs.readFileSync(githubOutput, "utf8")
|
||||
: "";
|
||||
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("encodeForm encodes form parameters", () => {
|
||||
const { encodeForm } = require(scriptPath);
|
||||
const result = encodeForm({
|
||||
grant_type: "client_credentials",
|
||||
client_id: "abc&def",
|
||||
client_secret: "test-secret",
|
||||
note: "x=y",
|
||||
});
|
||||
const params = new URLSearchParams(result);
|
||||
assert.equal(params.get("grant_type"), "client_credentials");
|
||||
assert.equal(params.get("client_id"), "abc&def");
|
||||
assert.equal(params.get("client_secret"), "test-secret");
|
||||
assert.equal(params.get("note"), "x=y");
|
||||
});
|
||||
|
||||
test("exits with error when app id is missing", async () => {
|
||||
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
|
||||
});
|
||||
|
||||
test("exits with error when app secret is missing", async () => {
|
||||
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
|
||||
});
|
||||
|
||||
test("fetches token and writes it to a private file", async () => {
|
||||
const { server, port } = await startServer((req, res, body) => {
|
||||
assert.equal(req.method, "POST");
|
||||
const params = new URLSearchParams(body);
|
||||
assert.equal(params.get("grant_type"), "client_credentials");
|
||||
assert.equal(params.get("client_id"), "test_app_id");
|
||||
assert.equal(params.get("client_secret"), "test-secret");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes("::add-mask::test-token"));
|
||||
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
|
||||
|
||||
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
|
||||
assert.ok(fs.existsSync(tatPath), "token file should exist");
|
||||
|
||||
const stat = fs.statSync(tatPath);
|
||||
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
|
||||
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes(`path=${tatPath}`),
|
||||
"should write path to GITHUB_OUTPUT",
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("retries an interrupted response and then succeeds", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
abortResponse(res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
|
||||
assert.equal(requestCount, 2);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("fails after every interrupted response is retried", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
abortResponse(res);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.equal(requestCount, 4);
|
||||
assert.match(result.stderr, /Failed to fetch tenant access token/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("exits with error after all retries fail", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 500, error: "server error" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.equal(requestCount, 4);
|
||||
assert.match(result.stderr, /Failed to fetch tenant access token/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
110
scripts/release-preflight.js
Normal file
110
scripts/release-preflight.js
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
const REHEARSAL_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isReleaseVersion(value) {
|
||||
return typeof value === "string" &&
|
||||
(STABLE_VERSION_PATTERN.test(value) || REHEARSAL_VERSION_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isReleaseVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must use X.Y.Z or the rehearsal form X.Y.Z-beta.N`,
|
||||
observed,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isReleaseVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use vX.Y.Z or the rehearsal form vX.Y.Z-beta.N",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
611
scripts/release-preflight.test.js
Normal file
611
scripts/release-preflight.test.js
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const {
|
||||
validateReleasePreflight,
|
||||
} = require("./release-preflight");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function createReleaseFixture(t, env = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-test-"));
|
||||
const scriptsDir = path.join(root, "scripts");
|
||||
const binDir = path.join(root, "bin");
|
||||
const stateDir = path.join(root, "state");
|
||||
const logPath = path.join(root, "git-calls.jsonl");
|
||||
const npmLogPath = path.join(root, "npm-calls.jsonl");
|
||||
fs.mkdirSync(scriptsDir);
|
||||
fs.mkdirSync(binDir);
|
||||
fs.mkdirSync(stateDir);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/release-preflight.js"),
|
||||
path.join(scriptsDir, "release-preflight.js"),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/tag-release.sh"),
|
||||
path.join(scriptsDir, "tag-release.sh"),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "package.json"), '{"version":"1.2.3-beta.0"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package-lock.json"),
|
||||
'{"version":"1.2.3-beta.0","packages":{"":{"version":"1.2.3-beta.0"}}}\n',
|
||||
);
|
||||
|
||||
const fakeGitPath = path.join(binDir, "git");
|
||||
fs.writeFileSync(fakeGitPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const stateDir = process.env.FAKE_GIT_STATE_DIR;
|
||||
const localTagPath = path.join(stateDir, "local-tag");
|
||||
if (process.cwd() !== process.env.FAKE_EXPECTED_GIT_CWD) {
|
||||
process.stderr.write("git invoked outside repository root: " + process.cwd() + "\n");
|
||||
process.exit(96);
|
||||
}
|
||||
fs.appendFileSync(process.env.FAKE_GIT_LOG, JSON.stringify(args) + "\n");
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(value + "\n");
|
||||
}
|
||||
|
||||
switch (args[0]) {
|
||||
case "branch":
|
||||
print(process.env.FAKE_BRANCH || "test/npm-staged-publish-rehearsal");
|
||||
break;
|
||||
case "status":
|
||||
if (process.env.FAKE_STATUS_OUTPUT) print(process.env.FAKE_STATUS_OUTPUT);
|
||||
break;
|
||||
case "fetch":
|
||||
break;
|
||||
case "rev-parse": {
|
||||
const ref = args[args.length - 1];
|
||||
if (ref === "HEAD") {
|
||||
print(process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref === "FETCH_HEAD^{commit}") {
|
||||
print(process.env.FAKE_REHEARSAL_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref.startsWith("refs/tags/")) {
|
||||
if (fs.existsSync(localTagPath)) {
|
||||
print(fs.readFileSync(localTagPath, "utf8").trim());
|
||||
break;
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write("unexpected rev-parse ref: " + ref + "\n");
|
||||
process.exit(97);
|
||||
break;
|
||||
}
|
||||
case "ls-remote": {
|
||||
const tagRef = args.find((arg) => arg.startsWith("refs/tags/") && !arg.endsWith("^{}"));
|
||||
const kind = process.env.FAKE_REMOTE_TAG_KIND || "absent";
|
||||
if (kind === "lightweight" || kind === "annotated") {
|
||||
print(process.env.FAKE_REMOTE_TAG_SHA + "\t" + tagRef);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "show":
|
||||
print(process.env.FAKE_WORKFLOW);
|
||||
break;
|
||||
case "tag":
|
||||
fs.writeFileSync(localTagPath, args[2] || process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
case "push": {
|
||||
const failedMarker = path.join(stateDir, "push-failed");
|
||||
if (process.env.FAKE_PUSH_FAIL_ONCE && !fs.existsSync(failedMarker)) {
|
||||
fs.writeFileSync(failedMarker, "1");
|
||||
process.exit(Number(process.env.FAKE_PUSH_FAIL_ONCE));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write("unexpected git command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(fakeGitPath, 0o755);
|
||||
|
||||
const fakeNpmPath = path.join(binDir, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\n");
|
||||
if (args[0] !== "view") {
|
||||
process.stderr.write("unexpected npm command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
const output = process.env.FAKE_NPM_VIEW_OUTPUT || "npm error code E404\nnpm error 404 Not Found";
|
||||
(Number(process.env.FAKE_NPM_VIEW_STATUS || "1") === 0 ? process.stdout : process.stderr).write(output + "\n");
|
||||
process.exit(Number(process.env.FAKE_NPM_VIEW_STATUS || "1"));
|
||||
`);
|
||||
fs.chmodSync(fakeNpmPath, 0o755);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
return {
|
||||
root,
|
||||
stateDir,
|
||||
logPath,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
LANG: "C",
|
||||
LC_ALL: "C",
|
||||
FAKE_GIT_LOG: logPath,
|
||||
FAKE_NPM_LOG: npmLogPath,
|
||||
FAKE_GIT_STATE_DIR: stateDir,
|
||||
FAKE_EXPECTED_GIT_CWD: fs.realpathSync(root),
|
||||
FAKE_HEAD_SHA: "aaaaaaaa",
|
||||
FAKE_REHEARSAL_SHA: "aaaaaaaa",
|
||||
FAKE_WORKFLOW: "args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
...env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runTagRelease(fixture, options = {}) {
|
||||
const { cwd = fixture.root, args = [], input = "" } = options;
|
||||
return spawnSync("bash", [path.join(fixture.root, "scripts/tag-release.sh"), ...args], {
|
||||
cwd,
|
||||
env: fixture.env,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function readGitCalls(fixture) {
|
||||
if (!fs.existsSync(fixture.logPath)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readFileSync(fixture.logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function assertNoTagOperations(calls) {
|
||||
const tagOperations = calls.filter((args) =>
|
||||
args[0] === "ls-remote" ||
|
||||
args[0] === "tag" ||
|
||||
args[0] === "push" ||
|
||||
(args[0] === "rev-parse" && args.some((arg) => arg.startsWith("refs/tags/"))),
|
||||
);
|
||||
assert.deepEqual(tagOperations, []);
|
||||
}
|
||||
|
||||
function assertNoTagWrites(calls) {
|
||||
assert.equal(calls.some((args) => args[0] === "tag" || args[0] === "push"), false);
|
||||
}
|
||||
|
||||
function validInputs(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertStructuredError(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
assert.ok(result.error.message.length > 0);
|
||||
assert.equal(typeof result.error.observed, "object");
|
||||
assert.equal(typeof result.error.hint, "string");
|
||||
assert.ok(result.error.hint.length > 0);
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable and beta rehearsal versions", () => {
|
||||
for (const version of ["1.2.3", "1.2.3-beta.0"]) {
|
||||
const { packageJson, packageLockJson } = validInputs(version);
|
||||
|
||||
assert.deepEqual(validateReleasePreflight(packageJson, packageLockJson), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: null,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, `v${version}`),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: version,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prerelease forms other than beta rehearsal versions", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3-rc.1");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects build metadata package versions with the stable release contract", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3+build.7");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid and missing package or lock SemVer values", () => {
|
||||
const invalid = validInputs();
|
||||
invalid.packageJson.version = "01.2.3";
|
||||
const missing = validInputs();
|
||||
delete missing.packageLockJson.packages[""].version;
|
||||
|
||||
for (const result of [
|
||||
validateReleasePreflight(invalid.packageJson, invalid.packageLockJson),
|
||||
validateReleasePreflight(missing.packageJson, missing.packageLockJson),
|
||||
]) {
|
||||
assertStructuredError(result);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a top-level package-lock version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.4",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a package-lock root package version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.4",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid and mismatched tags", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
|
||||
for (const tag of ["1.2.3", "v01.2.3", "v1.2.4"]) {
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson, tag);
|
||||
assertStructuredError(result);
|
||||
assert.equal(result.error.observed.tag, tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("release configuration", () => {
|
||||
it("writes success to stdout and structured failures to stderr", () => {
|
||||
const scriptPath = path.join(repoRoot, "scripts/release-preflight.js");
|
||||
const packageVersion = require(path.join(repoRoot, "package.json")).version;
|
||||
const success = spawnSync(process.execPath, [scriptPath, "--tag", `v${packageVersion}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const failure = spawnSync(process.execPath, [scriptPath, "--tag", "invalid"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(success.status, 0);
|
||||
assert.equal(success.stderr, "");
|
||||
assert.deepEqual(JSON.parse(success.stdout), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion,
|
||||
lockVersion: packageVersion,
|
||||
lockRootVersion: packageVersion,
|
||||
tagVersion: packageVersion,
|
||||
},
|
||||
});
|
||||
assert.equal(failure.status, 1);
|
||||
assert.equal(failure.stdout, "");
|
||||
assertStructuredError(JSON.parse(failure.stderr));
|
||||
});
|
||||
|
||||
it("keeps package metadata synchronized without changing the Node engine", () => {
|
||||
const packageJson = require(path.join(repoRoot, "package.json"));
|
||||
const packageLockJson = require(path.join(repoRoot, "package-lock.json"));
|
||||
|
||||
assert.equal(packageJson.scripts["release:check"], "node scripts/release-preflight.js");
|
||||
assert.equal(packageJson.engines.node, ">=16");
|
||||
assert.equal(packageLockJson.version, packageJson.version);
|
||||
assert.equal(packageLockJson.packages[""].version, packageJson.version);
|
||||
});
|
||||
|
||||
it("runs every release gate before any tag query, creation, or push", () => {
|
||||
const script = fs.readFileSync(path.join(repoRoot, "scripts/tag-release.sh"), "utf8");
|
||||
const preflight = script.indexOf('node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"');
|
||||
const requiredGates = [
|
||||
'CURRENT_BRANCH=$(git branch --show-current)',
|
||||
'git status --porcelain',
|
||||
'git fetch origin "${REHEARSAL_BRANCH}"',
|
||||
'git rev-parse "FETCH_HEAD^{commit}"',
|
||||
'git show "${HEAD_SHA}:.github/workflows/release.yml"',
|
||||
'npm view "@larksuite/cli@${VERSION}" version',
|
||||
];
|
||||
const tagOperations = [
|
||||
'git rev-parse -q --verify "refs/tags/${TAG}"',
|
||||
'git ls-remote --tags origin "refs/tags/${TAG}"',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
];
|
||||
|
||||
assert.ok(preflight >= 0, "release preflight invocation is missing");
|
||||
assertInOrder(script, [
|
||||
'REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"',
|
||||
'cd "${REPO_ROOT}"',
|
||||
'node "${SCRIPT_DIR}/release-preflight.js"',
|
||||
]);
|
||||
assert.equal(script.includes("require('${REPO_ROOT}/package.json')"), false);
|
||||
for (const gate of requiredGates) {
|
||||
const index = script.indexOf(gate);
|
||||
assert.ok(index >= 0, `required release gate is missing: ${gate}`);
|
||||
assert.ok(index < script.indexOf(tagOperations[0]), `${gate} must run before tag queries`);
|
||||
}
|
||||
for (const operation of tagOperations) {
|
||||
const index = script.indexOf(operation);
|
||||
assert.ok(index >= 0, `tag operation is missing: ${operation}`);
|
||||
assert.ok(preflight < index, `preflight must run before: ${operation}`);
|
||||
}
|
||||
assertInOrder(script, [
|
||||
'if [ "${PUSH_TAG}" != true ]',
|
||||
'read -r CONFIRM_TAG',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tag-release.sh behavior", () => {
|
||||
it("runs repository checks from the script repository when invoked elsewhere", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-cwd-"));
|
||||
t.after(() => fs.rmSync(outside, { recursive: true, force: true }));
|
||||
|
||||
const result = runTagRelease(fixture, { cwd: outside });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("rejects a non-rehearsal branch before querying or modifying tags", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_BRANCH: "feature/release" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be created from test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects a dirty working tree before tag operations", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_STATUS_OUTPUT: " M README.md" });
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /working tree must be clean/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects HEAD that differs from the fetched rehearsal branch", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_REHEARSAL_SHA: "bbbbbbbb" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /HEAD must exactly match origin\/test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("compares HEAD with the exact fetched rehearsal commit", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "fetch origin test/npm-staged-publish-rehearsal"));
|
||||
assert.ok(calls.some((args) => args.join(" ") === "rev-parse FETCH_HEAD^{commit}"));
|
||||
assert.equal(calls.some((args) => args.includes("origin/test/npm-staged-publish-rehearsal")), false);
|
||||
});
|
||||
|
||||
it("fails when the local tag already exists", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.stateDir, "local-tag"), "bbbbbbbb");
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /local tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "ls-remote"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
});
|
||||
|
||||
it("fails when a lightweight or annotated remote tag already exists", (t) => {
|
||||
for (const kind of ["lightweight", "annotated"]) {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_REMOTE_TAG_KIND: kind,
|
||||
FAKE_REMOTE_TAG_SHA: "aaaaaaaa",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1, `${kind}: ${result.stderr}`);
|
||||
assert.match(result.stderr, /remote tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "tag"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
}
|
||||
});
|
||||
|
||||
it("check mode completes without creating or pushing a tag", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /No tag was created or pushed/);
|
||||
assertNoTagWrites(calls);
|
||||
});
|
||||
|
||||
it("rejects a production version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"1.2.3"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.root, "package-lock.json"),
|
||||
'{"version":"1.2.3","packages":{"":{"version":"1.2.3"}}}\n',
|
||||
);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /require an X\.Y\.Z-beta\.N version/);
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
|
||||
it("rejects a workflow that can publish live", (t) => {
|
||||
for (const workflow of [
|
||||
"args: release --clean --skip=publish\nrun: npm publish --access public",
|
||||
"args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta\nrun: gh release create v1.2.3-beta.0",
|
||||
"args: release --clean --skip=publish\npermissions:\n contents: write\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
"args: release --clean --skip=publish\nenv:\n GITHUB_TOKEN: ${{ github.token }}\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
]) {
|
||||
const fixture = createReleaseFixture(t, { FAKE_WORKFLOW: workflow });
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be stage-only/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when npm cannot prove that the version is unused", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_OUTPUT: "npm error code ETIMEDOUT",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /npm version lookup failed/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("rejects an existing npm version", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_STATUS: "0",
|
||||
FAKE_NPM_VIEW_OUTPUT: "1.2.3-beta.0",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /already exists on npm/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("requires the full tag confirmation in push mode", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, { args: ["--push"], input: "no\n" });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /confirmation did not exactly match/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("pushes only the exact confirmed tag ref", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, {
|
||||
args: ["--push"],
|
||||
input: "v1.2.3-beta.0\n",
|
||||
});
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "tag v1.2.3-beta.0 aaaaaaaa"));
|
||||
assert.ok(calls.some((args) =>
|
||||
args.join(" ") === "push origin refs/tags/v1.2.3-beta.0:refs/tags/v1.2.3-beta.0"));
|
||||
assert.equal(
|
||||
calls.some((args) => args[0] === "push" && args.includes("--tags")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an invalid package version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"01.2.3"}\n');
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, "");
|
||||
assertStructuredError(JSON.parse(result.stderr));
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
});
|
||||
168
scripts/release-workflow.test.js
Normal file
168
scripts/release-workflow.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/release.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const previewWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/pkg-pr-new.yml"),
|
||||
"utf8",
|
||||
);
|
||||
function topLevelBlock(source, name) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`^${name}:\\n([\\s\\S]*?)(?=^[A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing top-level ${name} block`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function jobBlock(source, name) {
|
||||
const jobs = topLevelBlock(source, "jobs");
|
||||
const match = jobs.match(
|
||||
new RegExp(
|
||||
`^ ${name}:\\n([\\s\\S]*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing ${name} job`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing workflow fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `workflow fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionLines(job) {
|
||||
const match = job.match(/^ permissions:\n((?: .+\n)+)/m);
|
||||
assert.ok(match, "missing job permissions");
|
||||
return match[1].trim().split("\n").map((line) => line.trim()).sort();
|
||||
}
|
||||
|
||||
describe("release workflow contract", () => {
|
||||
it("has only the version-tag production trigger", () => {
|
||||
const trigger = topLevelBlock(releaseWorkflow, "on");
|
||||
|
||||
assert.match(trigger, /^on:\n push:\n tags:\n - 'v\*'\n+$/);
|
||||
for (const forbidden of [
|
||||
"workflow_dispatch:",
|
||||
"workflow_run:",
|
||||
"pull_request:",
|
||||
"pull_request_target:",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs preflight before every release side effect", () => {
|
||||
const preflight = jobBlock(releaseWorkflow, "preflight");
|
||||
|
||||
assert.deepEqual(permissionLines(preflight), ["contents: read"]);
|
||||
assertInOrder(preflight, [
|
||||
"actions/checkout@",
|
||||
"fetch-depth: 0",
|
||||
"actions/setup-node@",
|
||||
"node-version: '22.14.0'",
|
||||
"node scripts/release-preflight.js --tag \"$TAG\"",
|
||||
"git rev-parse --verify 'HEAD^{commit}'",
|
||||
"git rev-parse --verify \"refs/tags/${TAG}^{commit}\"",
|
||||
'if [[ "$TAG" == *-beta.* ]]',
|
||||
'git fetch origin "$REHEARSAL_BRANCH"',
|
||||
"git rev-parse --verify 'FETCH_HEAD^{commit}'",
|
||||
"git fetch origin main",
|
||||
'git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"',
|
||||
]);
|
||||
assert.equal(preflight.includes("gh release"), false);
|
||||
assert.equal(preflight.includes("npm publish"), false);
|
||||
});
|
||||
|
||||
it("builds a verified staging asset before approval", () => {
|
||||
const build = jobBlock(releaseWorkflow, "build-stage-assets");
|
||||
|
||||
assert.match(build, /needs: preflight/);
|
||||
assert.deepEqual(permissionLines(build), ["contents: read"]);
|
||||
assert.doesNotMatch(build, /^ environment:/m);
|
||||
assert.match(build, /actions\/setup-go@[0-9a-f]{40}/);
|
||||
assert.match(build, /actions\/setup-python@[0-9a-f]{40}/);
|
||||
assert.match(
|
||||
build,
|
||||
/actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/,
|
||||
);
|
||||
assert.match(build, /node-version: '22.14.0'/);
|
||||
assert.match(build, /registry-url: 'https:\/\/registry\.npmjs\.org'/);
|
||||
assert.match(build, /package-manager-cache: false/);
|
||||
assert.match(build, /npm install --global npm@11\.16\.0/);
|
||||
assert.match(build, /goreleaser\/goreleaser-action@[0-9a-f]{40}/);
|
||||
assert.match(build, /args: release --clean --skip=publish/);
|
||||
assertInOrder(build, [
|
||||
"actions/setup-go@",
|
||||
"actions/setup-python@",
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"goreleaser/goreleaser-action@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"cp dist/checksums.txt checksums.txt",
|
||||
"npm pack --ignore-scripts --json",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
"actions/upload-artifact@",
|
||||
]);
|
||||
assert.equal(build.includes("npm stage publish"), false);
|
||||
});
|
||||
|
||||
it("limits the protected job to verifying and staging the prepared npm asset", () => {
|
||||
const publish = jobBlock(releaseWorkflow, "stage-publish");
|
||||
|
||||
assert.match(publish, /needs: build-stage-assets/);
|
||||
assert.deepEqual(permissionLines(publish), ["id-token: write"]);
|
||||
assert.match(publish, /^ environment: npm-production$/m);
|
||||
assert.doesNotMatch(publish, /actions\/checkout@/);
|
||||
assert.doesNotMatch(publish, /goreleaser\/goreleaser-action@/);
|
||||
assert.doesNotMatch(publish, /GITHUB_TOKEN:/);
|
||||
assertInOrder(publish, [
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"actions/download-artifact@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
'npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta',
|
||||
]);
|
||||
for (const forbidden of [
|
||||
"gh release download",
|
||||
"npm view",
|
||||
"LOCAL_INTEGRITY",
|
||||
"REMOTE_INTEGRITY",
|
||||
"secrets.NPM_TOKEN",
|
||||
"NODE_AUTH_TOKEN",
|
||||
"GITHUB_TOKEN:",
|
||||
"gh release create",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
assert.equal(/(^|\s)npm publish(?:\s|$)/m.test(publish), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview isolation", () => {
|
||||
it("keeps preview publishing away from production credentials and registry", () => {
|
||||
assert.equal(previewWorkflow.includes("id-token: write"), false);
|
||||
assert.equal(previewWorkflow.includes("npm publish"), false);
|
||||
assert.equal(previewWorkflow.includes("registry.npmjs.org"), false);
|
||||
assert.equal(previewWorkflow.includes("secrets.NPM_TOKEN"), false);
|
||||
assert.equal(previewWorkflow.includes("NODE_AUTH_TOKEN"), false);
|
||||
});
|
||||
});
|
||||
@@ -3,49 +3,102 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
TAG="v${VERSION}"
|
||||
REHEARSAL_BRANCH="test/npm-staged-publish-rehearsal"
|
||||
PUSH_TAG=false
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "--push" ]; then
|
||||
PUSH_TAG=true
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
echo "Usage: $0 [--push]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
|
||||
echo "Error: rehearsal releases require an X.Y.Z-beta.N version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "${REHEARSAL_BRANCH}" ]; then
|
||||
echo "Error: rehearsal tags must be created from ${REHEARSAL_BRANCH}; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Error: the working tree must be clean before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git fetch origin "${REHEARSAL_BRANCH}"
|
||||
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_REHEARSAL_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_REHEARSAL_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/${REHEARSAL_BRANCH} before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKFLOW=$(git show "${HEAD_SHA}:.github/workflows/release.yml")
|
||||
if ! grep -Fq 'args: release --clean --skip=publish' <<<"${WORKFLOW}" ||
|
||||
! grep -Eq 'npm stage publish .*--tag beta' <<<"${WORKFLOW}" ||
|
||||
grep -Eq '(^|[[:space:]])npm publish([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'gh[[:space:]]+release([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'contents:[[:space:]]*write' <<<"${WORKFLOW}" ||
|
||||
grep -Fq 'GITHUB_TOKEN:' <<<"${WORKFLOW}"; then
|
||||
echo "Error: the tagged workflow must be stage-only, read-only for repository contents, and must not create a GitHub Release or publish npm live." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
NPM_VIEW_OUTPUT=$(npm view "@larksuite/cli@${VERSION}" version --registry=https://registry.npmjs.org/ 2>&1)
|
||||
NPM_VIEW_STATUS=$?
|
||||
set -e
|
||||
if [ "${NPM_VIEW_STATUS}" -eq 0 ]; then
|
||||
echo "Error: @larksuite/cli@${VERSION} already exists on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq 'E404|404 Not Found' <<<"${NPM_VIEW_OUTPUT}"; then
|
||||
echo "Error: npm version lookup failed; refusing to assume the version is unused." >&2
|
||||
echo "${NPM_VIEW_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${PUSH_TAG}" != true ]; then
|
||||
echo "Checks passed. No tag was created or pushed."
|
||||
echo "Run '$0 --push' only after reviewing the commit and workflow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Branch: ${CURRENT_BRANCH}"
|
||||
echo "Commit: ${HEAD_SHA}"
|
||||
printf 'Type %s to create and push this tag: ' "${TAG}"
|
||||
read -r CONFIRM_TAG
|
||||
if [ "${CONFIRM_TAG}" != "${TAG}" ]; then
|
||||
echo "Error: confirmation did not exactly match ${TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -469,9 +469,6 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
@@ -577,8 +574,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -587,8 +585,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -597,7 +596,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
}
|
||||
d.
|
||||
POST("/open-apis/base/v3/bases").
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const joinCalendarEventPath = "/open-apis/calendar/v4/events/join"
|
||||
|
||||
func resolveJoinCredential(runtime *common.RuntimeContext) string {
|
||||
if token := strings.TrimSpace(runtime.Str("join-token")); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(runtime.Str("share-link"))
|
||||
}
|
||||
|
||||
var CalendarJoin = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+join",
|
||||
Description: "Join a calendar event via the encrypted join token from an RSVP/share card, or via a shared meeting/event link",
|
||||
Risk: "write",
|
||||
Scopes: []string{"calendar:calendar.event:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: false,
|
||||
Flags: []common.Flag{
|
||||
{Name: "join-token", Desc: "encrypted join token issued with the RSVP/share card"},
|
||||
{Name: "share-link", Desc: "shared meeting/event link (…/calendar/share?token=xxx) or the bare share token"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(joinCalendarEventPath).
|
||||
Body(map[string]interface{}{"join_token": resolveJoinCredential(runtime)})
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
token := strings.TrimSpace(runtime.Str("join-token"))
|
||||
shareLink := strings.TrimSpace(runtime.Str("share-link"))
|
||||
if token == "" && shareLink == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"one of --join-token or --share-link is required").WithParam("--join-token")
|
||||
}
|
||||
if token != "" && shareLink != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--join-token and --share-link are mutually exclusive, pass only one").WithParam("--share-link")
|
||||
}
|
||||
if token != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--join-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if shareLink != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--share-link", shareLink); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
credential := resolveJoinCredential(runtime)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
joinCalendarEventPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"join_token": credential,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventID, _ := data["event_id"].(string)
|
||||
runtime.Out(map[string]interface{}{
|
||||
"event_id": eventID,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -18,6 +18,5 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
CalendarJoin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
permissionGrantPermHint = "可管理权限"
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
)
|
||||
|
||||
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
|
||||
@@ -121,7 +120,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
|
||||
}
|
||||
|
||||
func permissionGrantPermMessage() string {
|
||||
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
|
||||
return permissionGrantPerm
|
||||
}
|
||||
|
||||
func permissionGrantPermType(resourceType string) string {
|
||||
|
||||
@@ -31,6 +31,14 @@ func apiErrWithScopes(code int, msg string, subjects ...string) error {
|
||||
return errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := permissionGrantPermMessage(); got != "full_access" {
|
||||
t.Fatalf("permissionGrantPermMessage() = %q, want %q", got, "full_access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "perm-grant-test-skip",
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -173,11 +173,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
|
||||
if grant["message"] != wantMessage {
|
||||
t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "auto-grant failed") {
|
||||
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
|
||||
|
||||
@@ -59,7 +59,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
|
||||
@@ -73,10 +73,10 @@ func init() {
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("Task", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("Chat card", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
|
||||
@@ -975,8 +975,8 @@ func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
|
||||
"````Go\nfmt.Println(\"hi\")\n```\n````",
|
||||
"`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
|
||||
"``report`v1`.pdf``",
|
||||
"`任务``群聊卡片`",
|
||||
"`多维表格``多维表格``OKR`",
|
||||
"`Task``Chat card`",
|
||||
"`Base``Base``OKR`",
|
||||
}, "\n")
|
||||
|
||||
if got := convertToIMMarkdown(input, imCtx); got != want {
|
||||
|
||||
@@ -26,7 +26,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
|
||||
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
|
||||
|
||||
@@ -443,7 +443,7 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
"keyword": "bug|error",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
wantDesc := "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
@@ -34,8 +36,8 @@ func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
if flag.Desc != wantDesc {
|
||||
t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -772,7 +772,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
|
||||
var inputs []commentReplyElementInput
|
||||
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example text\"}]'", err).WithParam("--content")
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
|
||||
|
||||
@@ -59,7 +59,7 @@ var DriveCreateFolder = common.Shortcut{
|
||||
Desc("[1] Create folder").
|
||||
Body(spec.RequestBody())
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -90,6 +90,7 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -108,6 +109,10 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
if got.API[0].Body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
|
||||
}
|
||||
wantDesc := "After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder."
|
||||
if got.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
@@ -178,7 +183,7 @@ func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new folder." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -108,6 +108,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -117,6 +118,10 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
if len(got.API) != 4 {
|
||||
t.Fatalf("expected 4 API calls, got %d", len(got.API))
|
||||
}
|
||||
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
|
||||
if got.API[len(got.API)-1].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[len(got.API)-1].Desc, wantDesc)
|
||||
}
|
||||
|
||||
if got.API[0].Body != nil {
|
||||
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
|
||||
|
||||
@@ -1088,7 +1088,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
t.Fatalf("set --wiki-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -1100,7 +1100,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
PostUploadNote string `json:"post_upload_note"`
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
@@ -1123,6 +1124,10 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
|
||||
if got.PostUploadNote != wantPostUploadNote {
|
||||
t.Fatalf("post_upload_note = %q, want %q", got.PostUploadNote, wantPostUploadNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new file." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ var DriveUpload = common.Shortcut{
|
||||
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
|
||||
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
"In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
|
||||
@@ -137,7 +137,7 @@ var DriveUpload = common.Shortcut{
|
||||
"with_url": true,
|
||||
})
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -71,3 +72,18 @@ func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
|
||||
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadHelpTipUsesEnglishPermissionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions."
|
||||
for _, tip := range DriveUpload.Tips {
|
||||
if strings.Contains(tip, "automatic full_access") {
|
||||
if tip != want {
|
||||
t.Fatalf("DriveUpload full_access tip = %q, want %q", tip, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("DriveUpload full_access help tip not found")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new spreadsheet." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -156,10 +156,26 @@ func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
|
||||
"data": "",
|
||||
},
|
||||
nil, nil)
|
||||
rt = common.TestNewRuntimeContextWithIdentity(rt.Cmd, nil, core.AsBot)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
|
||||
t.Fatalf("DryRun should include folder_token, got: %s", got)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(dryRun.API) != 1 {
|
||||
t.Fatalf("dry-run API count = %d, want 1", len(dryRun.API))
|
||||
}
|
||||
wantDesc := "After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet."
|
||||
if dryRun.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", dryRun.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreatePreservesBackendURL(t *testing.T) {
|
||||
|
||||
@@ -115,7 +115,7 @@ var SheetCreate = common.Shortcut{
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
@@ -1213,7 +1213,7 @@ var TableGet = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"N/A\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1522,7 +1522,7 @@ func readCellFormat(cell map[string]interface{}) string {
|
||||
// inferColumnType decides a column's type from its data cells: a date
|
||||
// number_format guides each cell's type, but a column is given a non-string type
|
||||
// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a
|
||||
// number column with a stray "暂无", a date column with a bare count); declaring
|
||||
// number column with a stray "N/A", a date column with a bare count); declaring
|
||||
// number/date while a string value rides along makes the output inconsistent —
|
||||
// it breaks round-trip back into +table-put (which rejects a string in a number
|
||||
// column) and crashes pandas astype. So a mixed column degrades to string
|
||||
|
||||
@@ -1140,7 +1140,7 @@ func TestTableGet_InferColumnType(t *testing.T) {
|
||||
// Mixed number+text degrades to string (self-consistent: every value is then
|
||||
// a string), so the column round-trips and pandas doesn't choke. Numeric
|
||||
// coercion of the dirty cells is left to the caller (pandas to_numeric).
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("N/A", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
t.Errorf("mixed number+text col → %s, want string", typ)
|
||||
}
|
||||
// A bare number mixed into a date column must NOT stay date (would serial-
|
||||
|
||||
@@ -118,7 +118,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -79,27 +79,42 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Step 2: query minute_token via recording API
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
if minuteErr != nil {
|
||||
// Recording API failed — surface the error but keep data from step 1
|
||||
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
minuteHint = ""
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
// Step 2: query minute_token via recording API — only meaningful once the
|
||||
// meeting has ended. While it is still in progress the note/minute are not
|
||||
// generated yet, so skip the recording call and surface an informational
|
||||
// hint instead of letting an unclassified recording error fail the command.
|
||||
inProgress := meetingInProgress(meeting)
|
||||
var minuteHint string
|
||||
if inProgress {
|
||||
minuteHint = "meeting is still in progress; note and minute are not generated yet"
|
||||
} else {
|
||||
minuteToken, hint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
minuteHint = hint
|
||||
if minuteErr != nil {
|
||||
// Recording lookup is a best-effort supplement; step 1 already
|
||||
// succeeded, so degrade the failure to a hint rather than failing
|
||||
// the whole command.
|
||||
minuteHint = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
}
|
||||
}
|
||||
|
||||
// Add hints for empty resources (not errors, just informational)
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
// Add hints for empty resources (not errors, just informational). For an
|
||||
// in-progress meeting the "not found" wording is noise, so we only emit the
|
||||
// single in-progress hint below.
|
||||
if !inProgress {
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
}
|
||||
if minuteHint != "" {
|
||||
if result.Hint != "" {
|
||||
@@ -112,6 +127,36 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
|
||||
return result
|
||||
}
|
||||
|
||||
// meetingTimeField reads a meeting time field as a string regardless of whether
|
||||
// the API returned it as a JSON string or number. VC serializes int64
|
||||
// timestamps as strings, but coercing via %v keeps parsing robust either way;
|
||||
// float64(0) renders as "0", which parseFlexibleTime treats as "absent".
|
||||
func meetingTimeField(meeting map[string]any, key string) string {
|
||||
v, ok := meeting[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
// meetingInProgress reports whether a meeting is still ongoing, using the same
|
||||
// start/end heuristic as +meeting-events (meetingEventsMeetingFromPayload): a
|
||||
// meeting is ongoing when it has a start time but no end time, or its end time
|
||||
// is not after its start time. It reads the RAW timestamp fields, not the
|
||||
// FormatTime-rendered result strings, because parseFlexibleTime only accepts
|
||||
// Unix timestamps or RFC3339. Empty or "0" values are treated as absent.
|
||||
func meetingInProgress(meeting map[string]any) bool {
|
||||
start, hasStart := parseFlexibleTime(meetingTimeField(meeting, "start_time"))
|
||||
end, hasEnd := parseFlexibleTime(meetingTimeField(meeting, "end_time"))
|
||||
if !hasStart {
|
||||
return false
|
||||
}
|
||||
if !hasEnd {
|
||||
return true
|
||||
}
|
||||
return !end.After(start)
|
||||
}
|
||||
|
||||
// VCDetail gets meeting details including note_id and minute_token.
|
||||
var VCDetail = common.Shortcut{
|
||||
Service: "vc",
|
||||
|
||||
@@ -269,11 +269,58 @@ func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
|
||||
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty: a recording lookup failure must not fail the command", result.Error)
|
||||
}
|
||||
if strings.Contains(result.Hint, "minute_token") {
|
||||
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
|
||||
if !strings.Contains(result.Hint, "failed to query minutes") || !strings.Contains(result.Hint, "weird API error") {
|
||||
t.Errorf("hint = %q, want contains 'failed to query minutes' and 'weird API error'", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMeetingDetail_MeetingInProgress pins the in-progress behavior: when a
|
||||
// meeting is still ongoing (end_time not after start_time), +detail must not
|
||||
// call the recording API at all — it returns meeting metadata with an
|
||||
// informational hint and no error. Deliberately register NO recording stub so
|
||||
// that any recording call would fail on an unmatched request.
|
||||
func TestFetchMeetingDetail_MeetingInProgress(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_live",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"meeting": map[string]interface{}{
|
||||
"id": "m_live",
|
||||
"topic": "Live Meeting",
|
||||
"meeting_no": "912052453",
|
||||
// end_time == start_time signals an ongoing meeting.
|
||||
"start_time": "1752000000",
|
||||
"end_time": "1752000000",
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
if err := botExec(t, "detail-live", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_live")
|
||||
if result.Topic != "Live Meeting" {
|
||||
t.Errorf("topic = %q, want 'Live Meeting'", result.Topic)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty for an in-progress meeting", result.Error)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty for an in-progress meeting", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Hint, "in progress") {
|
||||
t.Errorf("hint = %q, want to mention the meeting is in progress", result.Hint)
|
||||
}
|
||||
if strings.Contains(result.Hint, "not found for this meeting") {
|
||||
t.Errorf("hint = %q, should not emit not-found noise for an in-progress meeting", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -73,7 +73,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new wiki node.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -635,7 +635,7 @@ func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new wiki node." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `form` | 否 | 表单值,**JSON 数组字符串**,不是普通对象;API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
`instances create --data.form` 是可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
@@ -173,7 +171,6 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 按发起时间范围筛选(秒级时间戳)
|
||||
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 按任务时间范围筛选(秒级时间戳)
|
||||
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
| `tasks[].instance_external_id` | 三方审批实例 ID,仅第三方审批实例存在 |
|
||||
| `tasks[].task_external_id` | 三方审批任务 ID,仅第三方审批任务存在 |
|
||||
| `tasks[].link` | 三方审批跳转链接 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
|
||||
@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 退回到发起节点(发起节点 ID 为 START)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.2
|
||||
version: 1.2.3
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -104,6 +104,8 @@ metadata:
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
||||
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
@@ -134,6 +136,8 @@ metadata:
|
||||
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
||||
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
|
||||
| `Invalid discriminator value`(字段写入缺 `type`) | 按完整提交规则读取当前字段,只改目标内容后提交;不要只补 `type` 重试 |
|
||||
| filter 报 `value of type array` / `Only string values` | 用 record/view 的 tuple `--filter-json`(非 `+data-query` 对象型),value 按字段 type 选标量或数组;见 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) |
|
||||
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
|
||||
| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
|
||||
|
||||
@@ -16,15 +16,20 @@
|
||||
|
||||
## 2. 各类型 CellValue
|
||||
|
||||
### 2.1 text / phone / url
|
||||
### 2.1 text
|
||||
|
||||
用字符串。URL 字段也传 URL 字符串;普通文本里可以保留 Markdown 风格链接文本,平台会按字段类型处理。
|
||||
text 字段的 `style.type` 影响单元格检查逻辑:
|
||||
`type=plain` 传 Markdown 格式的字符串。
|
||||
`type=url` 传一个带 title 的 Markdown 格式链接,或单独传一个链接。
|
||||
`type=phone` 传合法电话号码。
|
||||
`type=email` 传合法邮箱字符串。
|
||||
|
||||
```json
|
||||
{
|
||||
"标题": "Hello",
|
||||
"标题": "Hello, [lark-cli](https://github.com/larksuite/cli)",
|
||||
"官网": "[官网](https://example.com)",
|
||||
"联系电话": "1380000000000",
|
||||
"官网": "https://example.com"
|
||||
"邮箱": "owner@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ lark-cli base +field-create \
|
||||
lark-cli base +field-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Todo"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
|
||||
lark-cli base +field-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -51,6 +51,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。
|
||||
- 顶层最少包含:`name`、`type`。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。
|
||||
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;`datetime` / `user` 的动态填充用 `$slot`。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
|
||||
- `type` 不同,必填子字段不同:
|
||||
- `select`:`multiple` 控制是否多选,`options` 定义静态选项,`dynamic_options_source` 定义动态选项来源。静态与动态选项配置二选一,不能同时传。
|
||||
- `link`:必须有 `link_table`,可选 `bidirectional`、`bidirectional_link_field_name`。
|
||||
@@ -64,6 +65,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
"name": "状态",
|
||||
"type": "select",
|
||||
"multiple": false,
|
||||
"default_value": ["Todo"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Done", "hue": "Green", "lightness": "Light" }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层统一使用:`type` + `name` + 类型特有字段。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
|
||||
- 字段默认值使用 `default_value`,直接传对应 CellValue;支持范围只有 `text`、`number`、静态 `select`、`datetime`、`user`。清空默认值传 `null`;省略表示创建时不设置、更新时不修改。
|
||||
- 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。
|
||||
- `+field-update` 使用同样的字段 JSON 结构,但语义是 `PUT`;这是高风险写入操作,建议先 `+field-get` 再按目标状态全量提交,并带 `--yes`。
|
||||
- `type=formula` 或 `type=lookup` 创建/更新前,必须先读对应 guide。
|
||||
@@ -27,12 +28,12 @@
|
||||
|
||||
| 类型 | 最小必填字段 | 常见补充字段 |
|
||||
|------|--------------|-------------|
|
||||
| `text` | `type` `name` | `style.type` |
|
||||
| `number` | `type` `name` | `style` |
|
||||
| `select` | `type` `name` | `multiple` + `options`,或 `multiple` + `dynamic_options_source` |
|
||||
| `datetime` | `type` `name` | `style.format` |
|
||||
| `text` | `type` `name` | `style.type` `default_value` |
|
||||
| `number` | `type` `name` | `style` `default_value` |
|
||||
| `select` | `type` `name` | `multiple` + `options` + 静态 `default_value`,或 `multiple` + `dynamic_options_source` |
|
||||
| `datetime` | `type` `name` | `style.format` `default_value` |
|
||||
| `created_at` / `updated_at` | `type` `name` | `style.format` |
|
||||
| `user` / `group_chat` | `type` `name` | `multiple` |
|
||||
| `user` / `group_chat` | `type` `name` | `multiple`;仅 `user` 支持 `default_value` |
|
||||
| `created_by` / `updated_by` | `type` `name` | 无 |
|
||||
| `link` | `type` `name` `link_table` | `bidirectional` `bidirectional_link_field_name` |
|
||||
| `formula` | `type` `name` `expression` | 无 |
|
||||
@@ -47,31 +48,37 @@
|
||||
### 3.1 text
|
||||
|
||||
文本字段;电话、超链接、邮箱、条码也都属于 `text`,通过 `style.type` 区分。
|
||||
支持 `default_value`:静态 Markdown 文本字符串;`phone` style 必须是合法电话号码;`url` style 传一个 Markdown 链接或裸 URL;`email` style 必须是合法邮箱字符串,不要传 Markdown 链接或 `mailto:`。
|
||||
|
||||
最小写法(默认 `style.type` 为 `plain`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "标题"
|
||||
"name": "标题",
|
||||
"default_value": "默认标题"
|
||||
}
|
||||
```
|
||||
|
||||
常用写法:
|
||||
|
||||
默认值可以是 Markdown 文本
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "标题",
|
||||
"description": "主标题字段"
|
||||
"description": "主标题字段",
|
||||
"default_value": "未命名"
|
||||
}
|
||||
```
|
||||
|
||||
`style.type=phone` 时默认值是合法电话号码字符串。
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "联系电话",
|
||||
"style": { "type": "phone" }
|
||||
"style": { "type": "phone" },
|
||||
"default_value": "+8613800000000"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,7 +86,17 @@
|
||||
{
|
||||
"type": "text",
|
||||
"name": "官网",
|
||||
"style": { "type": "url" }
|
||||
"style": { "type": "url" },
|
||||
"default_value": "[官网](https://example.com)"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "邮箱",
|
||||
"style": { "type": "email" },
|
||||
"default_value": "owner@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -88,13 +105,15 @@
|
||||
### 3.2 number
|
||||
|
||||
数字字段;货币、进度、评分都属于 `number`,通过 `style.type` 区分。
|
||||
支持 `default_value`:静态 JSON number;所有 number style 都按这个规则写。
|
||||
|
||||
最小写法(默认 `style.type` 为 `plain`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "number",
|
||||
"name": "工时"
|
||||
"name": "工时",
|
||||
"default_value": 8
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,7 +137,8 @@
|
||||
"precision": 2,
|
||||
"percentage": false,
|
||||
"thousands_separator": true
|
||||
}
|
||||
},
|
||||
"default_value": 8
|
||||
}
|
||||
```
|
||||
|
||||
@@ -151,7 +171,8 @@
|
||||
{
|
||||
"type": "number",
|
||||
"name": "完成度",
|
||||
"style": { "type": "progress", "percentage": true, "color": "Blue" }
|
||||
"style": { "type": "progress", "percentage": true, "color": "Blue" },
|
||||
"default_value": 0.65
|
||||
}
|
||||
```
|
||||
|
||||
@@ -180,6 +201,7 @@
|
||||
#### 静态选项
|
||||
|
||||
支持字段:`multiple`、`options`
|
||||
支持 `default_value`:静态选项名数组;即使 `multiple=false` 也写数组,如 `["Todo"]`。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `false`
|
||||
@@ -189,12 +211,14 @@
|
||||
- `options[].hue` 可用:`Red`、`Orange`、`Yellow`、`Lime`、`Green`、`Turquoise`、`Wathet`、`Blue`、`Carmine`、`Purple`、`Gray` 缺省值为 `Blue`
|
||||
- `options[].lightness` 可用:`Lighter`、`Light`、`Standard`、`Dark`、`Darker` 缺省值为 `Lighter`
|
||||
- 选项里没有 `id`,只有 `name`。
|
||||
- 支持 `default_value` 配置:填选项名数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "select",
|
||||
"name": "状态",
|
||||
"multiple": false,
|
||||
"default_value": ["Todo"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Done", "hue": "Green", "lightness": "Light" }
|
||||
@@ -205,6 +229,7 @@
|
||||
#### 动态选项
|
||||
|
||||
支持字段:`multiple`、`dynamic_options_source`
|
||||
动态选项不支持 `default_value`。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `false`
|
||||
@@ -213,6 +238,7 @@
|
||||
- `dynamic_options_source.field_id` 填来源字段 id 或字段名
|
||||
- `dynamic_options_source` 仅创建支持;更新已有字段时不要传
|
||||
- 引用选项条件 / 级联筛选条件:这个功能在 Base 前端支持,属于 UI-only 属性,OpenAPI 里不支持,CLI 不能读取、创建或更新;不要根据接口返回缺失判断未配置
|
||||
- 动态选项不支持配置 `default_value`。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -229,13 +255,15 @@
|
||||
### 3.4 datetime
|
||||
|
||||
手动填写的日期/时间字段。系统时间用 `created_at` / `updated_at`。
|
||||
支持 `default_value`:静态时间字符串,或 `{ "$slot": "record_created_time" }`。`datetime + record_created_time` 是自动填充可编辑单元格;`created_at` 是只读创建时间元信息。
|
||||
|
||||
最小写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "datetime",
|
||||
"name": "截止时间"
|
||||
"name": "截止时间",
|
||||
"default_value": "2026-03-24 10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -251,7 +279,8 @@
|
||||
{
|
||||
"type": "datetime",
|
||||
"name": "截止时间",
|
||||
"style": { "format": "yyyy-MM-dd HH:mm" }
|
||||
"style": { "format": "yyyy-MM-dd HH:mm" },
|
||||
"default_value": { "$slot": "record_created_time" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -276,12 +305,19 @@
|
||||
### 3.6 user / group_chat
|
||||
|
||||
人员字段和群字段都支持 `multiple`。
|
||||
`user` 支持 `default_value`:人员 CellValue 数组,元素可用 `{ "id": "ou_xxx" }` 或 `{ "$slot": "current_user" }`;不要猜用户 ID。`group_chat` 不支持默认值。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `true`
|
||||
- `user` 字段支持 `default_value` 配置,`group_chat` 字段不支持 `default_value` 配置。
|
||||
|
||||
```json
|
||||
{ "type": "user", "name": "负责人", "multiple": true }
|
||||
{
|
||||
"type": "user",
|
||||
"name": "负责人",
|
||||
"multiple": true,
|
||||
"default_value": [{ "$slot": "current_user" }, { "id": "ou_xxx" }]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -488,3 +524,4 @@ Object(对象字段)、Button(按钮字段)、Stage(流程字段)暂
|
||||
- `number` 的精度、货币、进度、评分配置都放在 `style` 下,不要写顶层 `precision`。
|
||||
- `datetime` 是手动日期字段;系统时间请改用 `created_at` / `updated_at`。
|
||||
- `formula` / `lookup` 没读 guide 前不要直接写。
|
||||
- 只有 `text`、`number`、静态 `select`、`datetime`、`user` 支持 `default_value`;清空统一传 `"default_value": null`。其他字段类型不要配置默认值。
|
||||
|
||||
@@ -11,14 +11,14 @@ lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Doing"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
|
||||
--yes
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -47,6 +47,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
- 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name`、`type`,并补齐该类型所需关键配置。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
|
||||
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;传 `null` 清空,省略表示不修改现有默认值。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
|
||||
- `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
@@ -59,6 +60,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
"name": "状态",
|
||||
"type": "select",
|
||||
"multiple": false,
|
||||
"default_value": ["Doing"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Doing", "hue": "Orange", "lightness": "Light" },
|
||||
|
||||
@@ -174,11 +174,13 @@ lark-cli base +view-set-filter \
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
|
||||
@@ -38,7 +38,6 @@ lark-cli calendar +agenda --as user
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
| [`+join`](references/lark-calendar-join.md) | 加入日程:用 RSVP/分享卡片的加入 token,或分享会议/日程的链接加入 |
|
||||
|
||||
### `+get` — 单日程详情
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# calendar +join
|
||||
|
||||
加入一个日程(把当前身份加为参会人)。支持两种加入凭证,二选一:
|
||||
|
||||
1. **加入 token**:日历下发 RSVP 卡片 / 分享日程卡片时写入卡片的加密 `join_token`。
|
||||
2. **分享链接**:用户「分享会议」/「分享日程」得到的链接(形如 `…/calendar/share?token=xxx`),或链接里的 `token` 原始值。
|
||||
|
||||
两种凭证的加密方案不同,服务端会自动识别并按对应逻辑加入,CLI 只需把用户给到的那一个透传即可。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 用 RSVP/分享卡片里的加入 token 加入
|
||||
lark-cli calendar +join --join-token <join_token>
|
||||
|
||||
# 用分享会议/日程的链接加入(可直接粘贴完整链接)
|
||||
lark-cli calendar +join --share-link "https://xxx.feishu.cn/calendar/share?token=xxx"
|
||||
|
||||
# 也可以只传链接里的 token 原始值
|
||||
lark-cli calendar +join --share-link <share_token>
|
||||
|
||||
# bot 身份加入
|
||||
lark-cli calendar +join --share-link "https://xxx.feishu.cn/calendar/share?token=xxx" --as bot
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--join-token <token>` | 二选一 | RSVP/分享卡片中下发的加密加入 token |
|
||||
| `--share-link <link>` | 二选一 | 分享会议/日程的链接(`…/calendar/share?token=xxx`)或链接里的 `token` 原始值 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
- `--join-token` 与 `--share-link` **必须且只能提供一个**,同时给会报错。
|
||||
|
||||
## 返回
|
||||
|
||||
成功返回加入日程的 `event_id`。
|
||||
|
||||
## 提示
|
||||
|
||||
- 支持 `--as user`(默认)和 `--as bot` 两种身份。
|
||||
- **加入 token 场景**:需要当前身份确实收到过该卡片(在卡片所在的群里),这是「收到卡片」的凭证校验。
|
||||
- **分享链接场景**:链接本身即分享凭证,不做「在群里」校验;但分享人是否有权分享仍由服务端校验,无权限会返回加入失败。
|
||||
- 链接可以直接粘贴完整 URL,CLI/服务端会自动提取其中的 `token`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-rsvp](lark-calendar-rsvp.md) -- 回复(接受/拒绝/待定)日程
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-event
|
||||
version: 1.0.0
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Approval status changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -147,6 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
|
||||
| Topic | Reference | Coverage |
|
||||
|------------|------------------------------------------------------------------------------|---|
|
||||
| Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference |
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
||||
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
|
||||
170
skills/lark-event/references/lark-event-approval.md
Normal file
170
skills/lark-event/references/lark-event-approval.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Approval Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (2)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `approval.instance.status_changed_v4` | An approval instance status changed |
|
||||
| `approval.task.status_changed_v4` | An approval task status changed |
|
||||
|
||||
Both keys use a **Custom schema**. The raw Lark schema 2.0 envelope is flattened: event metadata is exposed as `type`, `event_id`, and `timestamp`, while approval business fields are exposed at the top level.
|
||||
|
||||
Both keys carry a **PreConsume hook** that subscribes the current authorized user through the Approval subscription APIs before listening. The consumer intentionally does **not** unsubscribe on exit; the server-side Approval subscription relation remains until it is canceled outside `event consume`. These keys require `--as user`.
|
||||
|
||||
## Listener and subscription selection
|
||||
|
||||
At the raw CLI level, each `event consume` process accepts exactly one EventKey. `approval.instance.status_changed_v4` and `approval.task.status_changed_v4` have different output shapes, so listening to both still means two processes.
|
||||
|
||||
For Approval only, `subscription_type` is an optional setup param used by PreConsume to register server-side Approval subscription relations before the local listener starts. It is **not** an output field, a local event filter, or a local subscription identity. The pushed event does not say which subscription relation caused delivery, and one business event can match both relations; deduplicate with `event_id` when needed.
|
||||
|
||||
`subscription_type` may be omitted, a single value, a comma-separated list, or a JSON string array:
|
||||
|
||||
```bash
|
||||
# Omitted: register both INVOLVED_APPROVAL and MANAGED_APPROVAL for this EventKey
|
||||
lark-cli event consume approval.instance.status_changed_v4 --as user
|
||||
|
||||
# Single relation
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Explicit multi-relation registration for one local consumer
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# JSON array form; quote it for the shell
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p 'subscription_type=["INVOLVED_APPROVAL","MANAGED_APPROVAL"]' \
|
||||
--as user
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `INVOLVED_APPROVAL` | Receive events where the current user is the approval requester or approver |
|
||||
| `MANAGED_APPROVAL` | Receive events under approval definitions managed by the current user |
|
||||
|
||||
User-intent inference:
|
||||
|
||||
| User intent | EventKey(s) | `subscription_type` |
|
||||
|---|---|---|
|
||||
| Mentions approval instances, approval forms, approval order/status, or "instance status" | `approval.instance.status_changed_v4` | infer from relation words below |
|
||||
| Mentions approval tasks, approval todo items, approver operations, or "task status" | `approval.task.status_changed_v4` | infer from relation words below |
|
||||
| Says "approval status changes/events" without saying task vs instance | both EventKeys | infer from relation words below |
|
||||
| Says "my approvals", "approvals involving me", "I requested/approved", "待我审批", "我发起/我参与" | requested EventKey(s) | `INVOLVED_APPROVAL` |
|
||||
| Says "approvals I manage", "managed definitions", "definitions managed by me", "我管理的审批定义" | requested EventKey(s) | `MANAGED_APPROVAL` |
|
||||
| Explicitly asks for both involved and managed, or says "all approval subscriptions" | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type`, or pass both values in one `-p` |
|
||||
| Relation is ambiguous and the user wants broad coverage | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type` so PreConsume registers both |
|
||||
|
||||
If the user's wording omits the relation and broad listening is acceptable, omit `subscription_type`. Ask only when registering both relations would be materially harmful.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `approval.instance.status_changed_v4` | `approval:instance:read` | user |
|
||||
| `approval.task.status_changed_v4` | `approval:task:read` | user |
|
||||
|
||||
## Subscription behavior
|
||||
|
||||
Startup calls the endpoint for the selected EventKey:
|
||||
|
||||
```text
|
||||
POST /open-apis/approval/v4/instances/subscription
|
||||
POST /open-apis/approval/v4/tasks/subscription
|
||||
```
|
||||
|
||||
For each resolved `subscription_type`, PreConsume sends one request body:
|
||||
|
||||
```json
|
||||
{"subscription_type":"INVOLVED_APPROVAL"}
|
||||
```
|
||||
|
||||
If `subscription_type` is omitted, PreConsume sends two registration requests for that EventKey: one with `INVOLVED_APPROVAL`, then one with `MANAGED_APPROVAL`. If listening to both instance and task events, run two consumers; each consumer may omit `subscription_type` to register both relations for its own EventKey.
|
||||
|
||||
Do not start two consumers for the same Approval EventKey merely to split `INVOLVED_APPROVAL` and `MANAGED_APPROVAL`. The server push and flattened output are keyed by EventKey and cannot be distinguished by subscription relation.
|
||||
|
||||
Shutdown behavior:
|
||||
|
||||
`event consume` does not call the Approval unsubscribe APIs when it exits. This applies to graceful exit, Ctrl+C / SIGTERM, stdin EOF, `--timeout`, and `--max-events`.
|
||||
|
||||
To stop future delivery for a user, cancel the Approval subscription relation outside this consumer. The unsubscribe APIs are separate operations and are not called by `event consume`.
|
||||
|
||||
## Output fields
|
||||
|
||||
Common fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type |
|
||||
| `event_id` | string | Globally unique event ID; use for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time in milliseconds, taken from `header.create_time` |
|
||||
|
||||
Instance event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `external_id` | string | Third-party approval instance id, when present |
|
||||
| `status` | string enum | `PENDING`, `APPROVED`, `REJECTED`, `CANCELED`, `DELETED`, `REVERTED`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
| `start_user` | object | Instance starter user IDs, omitted when unavailable |
|
||||
| `start_user.open_id` | string (open_id) | Instance starter open_id, when present |
|
||||
| `start_user.union_id` | string (union_id) | Instance starter union_id, when present |
|
||||
| `start_user.user_id` | string (user_id) | Instance starter tenant user_id, when present |
|
||||
|
||||
Task event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `task_id` | string | Approval task id |
|
||||
| `external_id` | string | Third-party approval external id, when present |
|
||||
| `task_external_id` | string | Third-party task external id, when emitted |
|
||||
| `assigned_user` | object | Task assignee or operator user IDs, omitted for automatic flows without an operator |
|
||||
| `assigned_user.open_id` | string (open_id) | Task assignee or operator open_id, when present |
|
||||
| `assigned_user.union_id` | string (union_id) | Task assignee or operator union_id, when present |
|
||||
| `assigned_user.user_id` | string (user_id) | Task assignee or operator tenant user_id, when present |
|
||||
| `status` | string enum | `REVERTED`, `PENDING`, `APPROVED`, `REJECTED`, `TRANSFERRED`, `ROLLBACK`, `DONE`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Stream approval instance updates broadly; registers both involved and managed relations
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user
|
||||
|
||||
# Stream approval instance updates only for approvals involving the current user
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Stream approval task updates for definitions managed by the current user
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Broad approval status listening:
|
||||
# run both EventKeys as separate processes; omit subscription_type so each registers both relations.
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user > approval-instance.ndjson &
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
--as user > approval-task.ndjson &
|
||||
wait
|
||||
|
||||
# Listen to both involved and managed task subscriptions with one local consumer.
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user > approval-task.ndjson
|
||||
|
||||
# Project a compact approval-task record
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user \
|
||||
--jq '{event_id, task_id, status, at: .operate_time}'
|
||||
```
|
||||
@@ -1464,6 +1464,7 @@
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
表格元素, 用于展示结构化数据
|
||||
宽高分配规则参考 HTML table 的整体值 / 子项值处理逻辑, 但在 SXSD 中做确定化约束, 以保证跨端实现一致。
|
||||
边框规则:
|
||||
- 后设置优先:相邻单元格线条只有一个颜色, 如果均设置, 则右下单元格的设置覆盖左上单元格
|
||||
- 不设置边框属性时使用默认样式
|
||||
@@ -1476,6 +1477,8 @@
|
||||
- id: 表格唯一标识符(可选)
|
||||
- topLeftX/topLeftY: 左上角坐标
|
||||
- flipX/flipY: 水平/垂直翻转
|
||||
- width: 表格目标总宽度(可选)。若设置, 优先用于为未填写列宽的列分配剩余宽度; 当所有列宽均为空时, 所有列均分该值; 当所有列均已填写且该值大于已填写列宽总和时, 多出空间继续分配给现有列, 默认按各列当前宽度作为权重分配; 当已填写列宽之和超过或无法容纳该值时, 保留已填写列宽, 并以最终列宽总和回写 table.width
|
||||
- height: 表格目标总高度(可选)。若设置, 优先用于为未填写行高的行分配剩余高度; 当所有行高均为空时, 所有行均分该值; 当所有行均已填写且该值大于已填写行高总和时, 多出空间继续分配给现有行, 默认按各行当前高度作为权重分配; 当已填写行高之和超过或无法容纳该值时, 保留已填写行高, 并以最终行高总和回写 table.height
|
||||
table 子元素:
|
||||
- colgroup: 列组元素, 用于定义列的宽度
|
||||
- tr: 行元素, 包含多个单元格
|
||||
@@ -1484,10 +1487,10 @@
|
||||
- col: 列元素
|
||||
col 属性:
|
||||
- span: 列跨数, 默认为1, 可选
|
||||
- width: 列宽度, 默认值为110, 可选
|
||||
- width: 列宽度输入值, 默认值为110, 可选。无 table.width 时, 已填写列保持原值, 空列使用默认值; 有 table.width 时, 已填写列保持原值, 空列优先均分剩余宽度; 若不存在空列且 table.width 大于已填写列宽总和, 则多出空间按各列当前宽度作为权重分配到所有列; 若剩余宽度不足则空列回退为默认值, 并以最终列宽总和作为 table.width
|
||||
|
||||
tr 属性:
|
||||
- height: 行高, 默认为单元格高度
|
||||
- height: 行高输入值, 默认值为37, 可选。无 table.height 时, 已填写行保持原值, 空行使用默认值; 有 table.height 时, 已填写行保持原值, 空行优先均分剩余高度; 若不存在空行且 table.height 大于已填写行高总和, 则多出空间按各行当前高度作为权重分配到所有行; 若剩余高度不足则空行回退为默认值, 并以最终行高总和作为 table.height。若行高低于内容高度, 需要手动修改行高
|
||||
tr 子元素:
|
||||
- td: 单元格元素, 用于显示数据
|
||||
|
||||
@@ -1543,6 +1546,8 @@
|
||||
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
|
||||
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
|
||||
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
|
||||
<xs:attribute name="width" type="sml:PositiveSize" use="optional"/>
|
||||
<xs:attribute name="height" type="sml:PositiveSize" use="optional"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@
|
||||
- `<tr>` 内为 `<td>`
|
||||
- `<td>` 内可放 `<content>`
|
||||
|
||||
`<table>` 可选设置 `width` 和 `height`,分别表示表格的目标总宽度和总高度:
|
||||
|
||||
```xml
|
||||
<table topLeftX="80" topLeftY="120" width="800" height="300">
|
||||
<colgroup>
|
||||
<col width="240"/>
|
||||
<col/>
|
||||
</colgroup>
|
||||
<tr height="80">
|
||||
<td><content textType="body"><p>表头 1</p></content></td>
|
||||
<td><content textType="body"><p>表头 2</p></content></td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
### `<chart>`
|
||||
|
||||
图表元素必须至少包含:
|
||||
|
||||
@@ -38,6 +38,8 @@ SXSD_ATTR_ALIASES = {
|
||||
"fontColor": "color",
|
||||
}
|
||||
SERVER_FILLED_SXSD_ATTRS = {"id"}
|
||||
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
||||
DEFAULT_TABLE_ROW_HEIGHT = 37
|
||||
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
|
||||
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
|
||||
|
||||
@@ -88,6 +90,84 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
|
||||
def sum_sizes(sizes: list[int | float]) -> int | float:
|
||||
return sum(sizes)
|
||||
|
||||
|
||||
def is_filled_size(size: int | float | None) -> bool:
|
||||
return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
|
||||
|
||||
|
||||
def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
|
||||
if not sizes:
|
||||
return sizes
|
||||
final_sizes = [
|
||||
size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
|
||||
for index, size in enumerate(sizes)
|
||||
]
|
||||
remaining_size = target_size - sum_sizes(final_sizes[:-1])
|
||||
if remaining_size >= 1:
|
||||
final_sizes[-1] = remaining_size
|
||||
return final_sizes
|
||||
|
||||
size_to_redistribute = 1 - remaining_size
|
||||
for index in range(len(final_sizes) - 2, -1, -1):
|
||||
reduction = min(final_sizes[index] - 1, size_to_redistribute)
|
||||
final_sizes[index] -= reduction
|
||||
size_to_redistribute -= reduction
|
||||
if size_to_redistribute == 0:
|
||||
final_sizes[-1] = 1
|
||||
return final_sizes
|
||||
|
||||
final_sizes[-1] = 1
|
||||
return final_sizes
|
||||
|
||||
|
||||
def solve_weighted_min_layout(
|
||||
input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
|
||||
) -> dict[str, Any]:
|
||||
filled_indexes: list[int] = []
|
||||
empty_indexes: list[int] = []
|
||||
base_sizes: list[int | float] = []
|
||||
for index, size in enumerate(input_sizes):
|
||||
if is_filled_size(size):
|
||||
filled_indexes.append(index)
|
||||
base_sizes.append(size)
|
||||
else:
|
||||
empty_indexes.append(index)
|
||||
base_sizes.append(0)
|
||||
filled_sum = sum_sizes(base_sizes)
|
||||
|
||||
if target_min_size is None:
|
||||
final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
if not filled_indexes:
|
||||
average_size = target_min_size / len(input_sizes)
|
||||
final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
if empty_indexes:
|
||||
remaining_size = target_min_size - filled_sum
|
||||
final_sizes = [*base_sizes]
|
||||
if remaining_size > 0:
|
||||
average_size = remaining_size / len(empty_indexes)
|
||||
empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
|
||||
for index, empty_size in zip(empty_indexes, empty_sizes):
|
||||
final_sizes[index] = empty_size
|
||||
else:
|
||||
for index in empty_indexes:
|
||||
final_sizes[index] = default_size
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
ratio = max(1, target_min_size / filled_sum)
|
||||
actual_size = max(target_min_size, filled_sum)
|
||||
if ratio == 1:
|
||||
return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
|
||||
final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
|
||||
|
||||
|
||||
def strip_xml(value: str) -> str:
|
||||
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
||||
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
||||
@@ -515,8 +595,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
|
||||
kind, attrs = match.group(1), match.group(2)
|
||||
content = ""
|
||||
if kind == "shape":
|
||||
close_index = slide_xml.find("</shape>", match.end())
|
||||
if kind in {"shape", "table"}:
|
||||
close_index = slide_xml.find(f"</{kind}>", match.end())
|
||||
if close_index != -1:
|
||||
content = slide_xml[match.end() : close_index]
|
||||
|
||||
@@ -525,6 +605,15 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
rotation = extract_numeric_attribute(attrs, "rotation") or 0
|
||||
table_layouts: dict[str, dict[str, Any] | None] = {}
|
||||
if kind == "table":
|
||||
width, table_layouts["width"] = resolve_table_dimension(
|
||||
content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
|
||||
)
|
||||
height, table_layouts["height"] = resolve_table_dimension(
|
||||
content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
|
||||
)
|
||||
if all(value is not None for value in [x, y, width, height]):
|
||||
element = {
|
||||
"id": element_id,
|
||||
@@ -534,8 +623,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"rotation": rotation,
|
||||
"order": len(elements),
|
||||
}
|
||||
if kind == "table":
|
||||
element.update(
|
||||
{
|
||||
"declared_width": extract_numeric_attribute(attrs, "width"),
|
||||
"declared_height": extract_numeric_attribute(attrs, "height"),
|
||||
"table_layouts": table_layouts,
|
||||
}
|
||||
)
|
||||
if kind == "shape":
|
||||
element.update(
|
||||
{
|
||||
@@ -867,11 +965,158 @@ def detect_whiteboard_external_overlaps(
|
||||
return issues
|
||||
|
||||
|
||||
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
||||
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
||||
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
||||
return bbox
|
||||
|
||||
rotation = element["rotation"]
|
||||
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
||||
rotation = 0
|
||||
rotation %= 360
|
||||
if math.isclose(rotation, 0, abs_tol=1e-9):
|
||||
return bbox
|
||||
radians = math.radians(rotation)
|
||||
sine = abs(math.sin(radians))
|
||||
cosine = abs(math.cos(radians))
|
||||
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
|
||||
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
|
||||
rotated_width = element["width"] * cosine + element["height"] * sine
|
||||
rotated_height = element["width"] * sine + element["height"] * cosine
|
||||
return {
|
||||
"x": element["x"] - (rotated_width - element["width"]) / 2,
|
||||
"y": element["y"] - (rotated_height - element["height"]) / 2,
|
||||
"width": rotated_width,
|
||||
"height": rotated_height,
|
||||
}
|
||||
|
||||
|
||||
def detect_elements_out_of_canvas(
|
||||
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in (
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] == "text")
|
||||
):
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
"top": max(-bbox["y"], 0),
|
||||
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
|
||||
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
|
||||
}
|
||||
overflow_details = [
|
||||
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
|
||||
]
|
||||
if not overflow_details:
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": f'{element["kind"]}_out_of_canvas',
|
||||
"elements": [element["id"]],
|
||||
"canvas": {"width": slide_width, "height": slide_height},
|
||||
"bbox": bbox,
|
||||
"overflow": overflow,
|
||||
"message": (
|
||||
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
|
||||
f'({", ".join(overflow_details)})'
|
||||
),
|
||||
"hint": (
|
||||
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
|
||||
"slides."
|
||||
if element["kind"] == "table"
|
||||
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
|
||||
sizes: list[int | float | None] = []
|
||||
for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
|
||||
attrs = match.group(1)
|
||||
span = extract_numeric_attribute(attrs, "span") or 1
|
||||
span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
|
||||
sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
|
||||
return sizes
|
||||
|
||||
|
||||
def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
|
||||
return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
|
||||
|
||||
|
||||
def resolve_table_dimension(
|
||||
table_xml: str,
|
||||
declared_size: int | float | None,
|
||||
extract_sizes: Any,
|
||||
default_size: int | float,
|
||||
) -> tuple[int | float | None, dict[str, Any] | None]:
|
||||
input_sizes = extract_sizes(table_xml)
|
||||
if not input_sizes:
|
||||
return declared_size, None
|
||||
layout = solve_weighted_min_layout(
|
||||
input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
|
||||
)
|
||||
return layout["actual_size"], layout
|
||||
|
||||
|
||||
def format_size(size: int | float) -> str:
|
||||
return f"{size:g}"
|
||||
|
||||
|
||||
def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
dimensions = {
|
||||
"width": ("col", "column widths"),
|
||||
"height": ("tr", "row heights"),
|
||||
}
|
||||
for table in (element for element in elements if element["kind"] == "table"):
|
||||
for dimension, (child_tag, child_description) in dimensions.items():
|
||||
target_size = table[f"declared_{dimension}"]
|
||||
if not is_filled_size(target_size):
|
||||
continue
|
||||
layout = table["table_layouts"][dimension]
|
||||
if layout is None:
|
||||
continue
|
||||
actual_size = layout["actual_size"]
|
||||
if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "info",
|
||||
"code": "table_resolved_size_mismatch",
|
||||
"elements": [table["id"]],
|
||||
"dimension": dimension,
|
||||
"declared_size": target_size,
|
||||
"resolved_size": actual_size,
|
||||
"resolved_sizes": layout["final_sizes"],
|
||||
"message": (
|
||||
f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
|
||||
f"{child_description} resolve to {format_size(actual_size)}px"
|
||||
),
|
||||
"hint": (
|
||||
f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
|
||||
f"so their resolved total matches {format_size(target_size)}px."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
elements = extract_elements(slide_xml)
|
||||
issues: list[dict[str, Any]] = detect_whiteboard_external_overlaps(elements, slide_width, slide_height)
|
||||
issues: list[dict[str, Any]] = [
|
||||
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
|
||||
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
@@ -896,7 +1141,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
|
||||
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
|
||||
"issues": [xml_error],
|
||||
"slides": [],
|
||||
}
|
||||
@@ -908,10 +1153,16 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
if namespace_issues:
|
||||
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {"slide_count": 0, "error_count": error_count, "warning_count": warning_count},
|
||||
"summary": {
|
||||
"slide_count": 0,
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
},
|
||||
"issues": top_level_issues,
|
||||
"slides": [],
|
||||
}
|
||||
@@ -924,10 +1175,17 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
|
||||
result = {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
|
||||
"summary": {
|
||||
"slide_count": len(slides),
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
if top_level_issues:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import xml_text_overlap_lint
|
||||
|
||||
@@ -212,7 +217,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
|
||||
self.assertEqual(result["summary"]["slide_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
|
||||
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -596,7 +602,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
|
||||
|
||||
def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
|
||||
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -613,8 +619,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(issue["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
|
||||
|
||||
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -669,7 +678,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(elements[1]["fontSize"], 28)
|
||||
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
|
||||
|
||||
def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
|
||||
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -683,7 +692,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
|
||||
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -698,7 +707,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
|
||||
def test_lint_xml_allows_full_bleed_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -712,6 +721,339 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
|
||||
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
|
||||
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
|
||||
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
|
||||
[
|
||||
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
|
||||
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
|
||||
<chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
|
||||
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
|
||||
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
|
||||
|
||||
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
|
||||
<shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
|
||||
<chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 3)
|
||||
self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
|
||||
self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
|
||||
self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
|
||||
|
||||
def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["elements"], ["score-table"])
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
|
||||
self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})
|
||||
|
||||
def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})
|
||||
|
||||
def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="implicit-size-table" topLeftX="850" topLeftY="480">
|
||||
<colgroup><col/><col/></colgroup>
|
||||
<tr><td/><td/></tr>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})
|
||||
|
||||
def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
|
||||
<colgroup><col width="100"/><col width="100"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
||||
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
|
||||
self.assertEqual(canvas_issue["overflow"]["right"], 40)
|
||||
self.assertEqual(mismatch_issue["dimension"], "width")
|
||||
self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])
|
||||
|
||||
def test_lint_xml_uses_the_same_anonymous_table_id_for_all_table_diagnostics(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
|
||||
<img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
|
||||
<table topLeftX="900" topLeftY="80" width="100" height="40">
|
||||
<colgroup><col width="100"/><col width="100"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
||||
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
||||
self.assertEqual(canvas_issue["elements"], ["table-3"])
|
||||
self.assertEqual(mismatch_issue["elements"], ["table-3"])
|
||||
|
||||
def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
|
||||
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
||||
<tr height="40"><td/><td/><td/></tr>
|
||||
<tr height="60"><td/><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "info")
|
||||
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
|
||||
self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
|
||||
self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)
|
||||
|
||||
def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
|
||||
<colgroup><col width="100"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
|
||||
<colgroup><col/><col/></colgroup>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["dimension"], "width")
|
||||
self.assertEqual(issue["resolved_sizes"], [1, 1])
|
||||
self.assertEqual(issue["resolved_size"], 2)
|
||||
|
||||
def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
|
||||
final_sizes = xml_text_overlap_lint.fill_last_size_gap([10, 10], 3)
|
||||
self.assertEqual(final_sizes, [2, 1])
|
||||
self.assertEqual(sum(final_sizes), 3)
|
||||
|
||||
def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
|
||||
cases = {
|
||||
"target-exact": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="360" height="150">
|
||||
<colgroup><col width="100"/><col width="200"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
0,
|
||||
),
|
||||
"declared-size-exceeds-target": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="200" height="80">
|
||||
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
||||
<tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
2,
|
||||
),
|
||||
"remaining-space-insufficient": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="80" height="30">
|
||||
<colgroup><col width="80"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
2,
|
||||
),
|
||||
"no-target-size": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120">
|
||||
<colgroup><col width="80"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
0,
|
||||
),
|
||||
}
|
||||
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for name, (table_xml, expected_info_count) in cases.items():
|
||||
with self.subTest(case=name):
|
||||
input_path = Path(temp_dir) / f"{name}.xml"
|
||||
input_path.write_text(
|
||||
f"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
|
||||
</presentation>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(script_path), "--input", str(input_path)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
result = json.loads(completed.stdout)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], expected_info_count)
|
||||
self.assertTrue(
|
||||
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
|
||||
result["slides"][0]["issues"],
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_for_whiteboard_external_boundary_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
@@ -92,7 +92,7 @@ metadata:
|
||||
### 3. 发送会中文本或会中表情(写操作)
|
||||
|
||||
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。发消息只需 `meeting_id`,不要先查 `+detail`。
|
||||
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`。
|
||||
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type`。`--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
|
||||
5. 支持普通 Feishu reaction emoji(如 `LOVE`、`SMILE`、`THUMBSUP`)和 4 个 VC 反馈 key(`VC_CanNotSee`、`VC_NoSound`、`VC_LooksGood`、`VC_SoundsClear`)。
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBase_BasicWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBase_RoleWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
|
||||
func TestCalendar_CreateEvent(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
|
||||
func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -39,6 +39,7 @@ func requireFreebusyEntry(t *testing.T, stdout string, startAt time.Time, endAt
|
||||
}
|
||||
|
||||
func TestCalendar_RSVPWorkflowAsUser(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCalendar_UpdateEventWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -52,6 +52,7 @@ func TestContact_LookupWorkflowAsUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContact_LookupWorkflowAsBot(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const (
|
||||
|
||||
func SkipWithoutUserToken(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
|
||||
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" || os.Getenv("TEST_USER_ACCESS_TOKEN") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,6 +75,27 @@ func SkipWithoutUserToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func SkipWithoutTenantAccessToken(t *testing.T) {
|
||||
t.Helper()
|
||||
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN")
|
||||
}
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
if appID == "" {
|
||||
appID = os.Getenv("LARKSUITE_CLI_APP_ID")
|
||||
}
|
||||
if token == "" || appID == "" {
|
||||
t.Skip("skipped: tenant test credentials not set")
|
||||
}
|
||||
|
||||
// Scope standard env credentials to tests that explicitly require a live
|
||||
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
|
||||
// config and dry-run CLI subprocesses from activating the env provider.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
|
||||
}
|
||||
|
||||
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
|
||||
func DryRunGet(stdout, path string) gjson.Result {
|
||||
if path == "" {
|
||||
@@ -225,13 +246,13 @@ func buildCommandEnv(req Request) []string {
|
||||
overrides[k] = v
|
||||
}
|
||||
// Keep user-token injection scoped to user-only test commands so bot
|
||||
// commands continue to use config-init credentials in the same process.
|
||||
// commands retain the process-level bot credentials.
|
||||
if req.DefaultAs == "user" {
|
||||
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
}
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
}
|
||||
for k, v := range overrides {
|
||||
|
||||
@@ -113,6 +113,19 @@ func TestSkipWithoutUserToken(t *testing.T) {
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("returns immediately when test user access token exists", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat-from-test-env")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutUserToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("accepts verified local auth status", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
@@ -146,6 +159,54 @@ func TestSkipWithoutUserToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
t.Run("skips when env tenant access token is missing", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.False(t, ran)
|
||||
})
|
||||
|
||||
t.Run("accepts standard tenant credentials", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app-from-env")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "test-token")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCmd(t *testing.T) {
|
||||
t.Run("returns stdout json on success", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
@@ -214,6 +275,8 @@ func TestRunCmd(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("injects user token env only for user commands", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
|
||||
|
||||
@@ -224,6 +287,10 @@ func TestRunCmd(t *testing.T) {
|
||||
env = buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
|
||||
func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
|
||||
func TestDocs_UpdateWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, asserts each delete returns `task_id`, queries every returned task via `drive +task_result --scenario task_check`, and verifies the targets disappear.
|
||||
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, converging every delete outcome to the resource-gone terminal state: async deletes (non-empty `task_id`) are verified via `drive +task_result --scenario task_check`, sync deletes (empty `task_id`) assert `deleted=true`, and the one verified backend transient (`server_error: "drive task failed"`) passes once the target is confirmed gone (retried up to 3 times otherwise); any other delete failure stays fatal.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -30,7 +30,7 @@
|
||||
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
|
||||
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_AppsPageURL; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; apps `/page/<token>` URL; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, apps `file_type=apps`, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder async deletion |
|
||||
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
|
||||
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
|
||||
}
|
||||
|
||||
370
tests/cli_e2e/drive/drive_delete_workflow_helper_test.go
Normal file
370
tests/cli_e2e/drive/drive_delete_workflow_helper_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeleteAsyncAndVerify(t *testing.T) {
|
||||
t.Run("sync delete without task_id skips task_result", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "sync")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_sync", "docx")
|
||||
assert.Empty(t, taskID)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "sync path must delete exactly once")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "sync path must still verify the resource is gone")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults), "sync path must not query task status")
|
||||
})
|
||||
|
||||
t.Run("transient failure with resource gone is tolerated", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_transient", "docx")
|
||||
assert.Empty(t, taskID)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "resource already gone must not trigger another delete attempt")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "transient failure must verify the terminal state")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
})
|
||||
|
||||
t.Run("failed delete retries until async success", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail-then-async")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "exists-then-gone")
|
||||
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_OK", "1")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
withFastDeleteWorkflowBackoff(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_retry", "docx")
|
||||
assert.Equal(t, "task_123", taskID)
|
||||
|
||||
assert.Equal(t, "2", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "2", readFakeCounter(t, counters.metas), "one terminal-state check after the failure plus the final visibility wait")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults), "async success must verify the task result")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsTransientDriveDeleteFailure locks the tolerance boundary: only the one
|
||||
// verified backend transient may fall through to terminal-state checking, so a
|
||||
// crash, a protocol regression, or any other error keeps failing the workflow
|
||||
// even when the resource happens to be gone.
|
||||
func TestIsTransientDriveDeleteFailure(t *testing.T) {
|
||||
t.Run("matches compact envelope", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "Deleting docx tok...\n{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"drive task failed\"}}",
|
||||
}
|
||||
assert.True(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("matches pretty-printed envelope from CI", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "Deleting docx NTw0...Rngb...\nDelete is async, polling task schedule|7663369798226545963...\n" +
|
||||
"{\n \"ok\": false,\n \"identity\": \"bot\",\n \"error\": {\n \"type\": \"api\",\n \"subtype\": \"server_error\",\n \"message\": \"drive task failed\"\n }\n}",
|
||||
}
|
||||
assert.True(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects other server errors", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"internal error\"}}",
|
||||
}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects non-server-error subtypes", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"permission_denied\",\"message\":\"drive task failed\"}}",
|
||||
}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects non-JSON output", func(t *testing.T) {
|
||||
result := &clie2e.Result{ExitCode: 2, Stderr: "panic: runtime error"}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects nil result", func(t *testing.T) {
|
||||
assert.False(t, isTransientDriveDeleteFailure(nil))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyRejectsUnexpectedFailure locks the P1 boundary
|
||||
// end-to-end by re-running this test binary as a subprocess that really calls
|
||||
// deleteAsyncAndVerify: an unrelated non-zero exit must fail the helper
|
||||
// immediately — no terminal-state check may rescue it even though meta reports
|
||||
// the resource gone. Fatalf cannot be observed on the parent *testing.T, so
|
||||
// the boundary is proven by the child process exiting non-zero AND the meta
|
||||
// endpoint never being reached. Removing the isTransientDriveDeleteFailure
|
||||
// guard from the main loop turns this test red.
|
||||
func TestDeleteAsyncAndVerifyRejectsUnexpectedFailure(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_unexpected",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "fail-unexpected",
|
||||
"FAKE_WORKFLOW_META_MODE": "gone",
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process on an unexpected delete error\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +delete failed with an unexpected error", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "unexpected failures must not fall through to terminal-state checking")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyFailsOnTaskResultFailure proves a non-zero
|
||||
// drive +task_result exit fails the workflow before the final visibility
|
||||
// polling: the task-result endpoint is reached once and the meta endpoint
|
||||
// never.
|
||||
func TestDeleteAsyncAndVerifyFailsOnTaskResultFailure(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_taskresult",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "async",
|
||||
"FAKE_WORKFLOW_META_MODE": "gone",
|
||||
// FAKE_WORKFLOW_TASK_RESULT_OK stays unset: +task_result exits 2.
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process when +task_result fails\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +task_result failed", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "task-result failure must abort before visibility polling")
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries proves the transient
|
||||
// tolerance is bounded: with the resource still present, exactly
|
||||
// deleteWorkflowMaxAttempts delete attempts (each followed by one terminal
|
||||
// state check) run before the workflow fails for good.
|
||||
func TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_exhausted",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "fail",
|
||||
"FAKE_WORKFLOW_META_MODE": "exists",
|
||||
"FAKE_WORKFLOW_FAST_BACKOFF": "1",
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process after exhausting retries\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +delete failed 3 times", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "3", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "3", readFakeCounter(t, counters.metas))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
}
|
||||
|
||||
// runDeleteWorkflowSubprocess re-runs this test binary anchored to the child
|
||||
// entry point below with the fake CLI and counter files wired in via env.
|
||||
func runDeleteWorkflowSubprocess(t *testing.T, fake string, counters fakeWorkflowCounters, env map[string]string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestDeleteAsyncAndVerifySubprocess$", "-test.v")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"FAKE_WORKFLOW_SUBPROCESS=1",
|
||||
clie2e.EnvBinaryPath+"="+fake,
|
||||
"FAKE_WORKFLOW_DELETE_STATE="+counters.deletes,
|
||||
"FAKE_WORKFLOW_META_STATE="+counters.metas,
|
||||
"FAKE_WORKFLOW_TASK_RESULT_STATE="+counters.taskResults,
|
||||
)
|
||||
for k, v := range env {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifySubprocess is the child entry point driven by
|
||||
// runDeleteWorkflowSubprocess. It does nothing in a normal test run.
|
||||
func TestDeleteAsyncAndVerifySubprocess(t *testing.T) {
|
||||
if os.Getenv("FAKE_WORKFLOW_SUBPROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
if os.Getenv("FAKE_WORKFLOW_FAST_BACKOFF") == "1" {
|
||||
deleteWorkflowRetryBackoff = time.Millisecond
|
||||
}
|
||||
deleteAsyncAndVerify(t, context.Background(), os.Getenv("FAKE_WORKFLOW_TOKEN"), "docx")
|
||||
}
|
||||
|
||||
type fakeWorkflowCounters struct {
|
||||
deletes string
|
||||
metas string
|
||||
taskResults string
|
||||
}
|
||||
|
||||
func newFakeWorkflowCounterPaths(t *testing.T) fakeWorkflowCounters {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
return fakeWorkflowCounters{
|
||||
deletes: filepath.Join(dir, "delete-attempts"),
|
||||
metas: filepath.Join(dir, "meta-calls"),
|
||||
taskResults: filepath.Join(dir, "task-result-calls"),
|
||||
}
|
||||
}
|
||||
|
||||
// setupFakeWorkflowCounters wires per-endpoint call counters into the fake CLI
|
||||
// so tests can assert exactly which commands ran.
|
||||
func setupFakeWorkflowCounters(t *testing.T) fakeWorkflowCounters {
|
||||
t.Helper()
|
||||
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_STATE", counters.deletes)
|
||||
t.Setenv("FAKE_WORKFLOW_META_STATE", counters.metas)
|
||||
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_STATE", counters.taskResults)
|
||||
return counters
|
||||
}
|
||||
|
||||
func readFakeCounter(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "0"
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func withFastDeleteWorkflowBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
original := deleteWorkflowRetryBackoff
|
||||
deleteWorkflowRetryBackoff = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
deleteWorkflowRetryBackoff = original
|
||||
})
|
||||
}
|
||||
|
||||
// mustWriteDriveDeleteWorkflowFakeCLI writes a fake lark-cli that emulates the
|
||||
// drive delete outcomes exercised by deleteAsyncAndVerify. Every endpoint
|
||||
// bumps a per-endpoint counter when its FAKE_WORKFLOW_*_STATE env is set, so
|
||||
// tests can assert call contracts. +task_result rejects every call unless
|
||||
// FAKE_WORKFLOW_TASK_RESULT_OK=1, which proves the sync path never queries
|
||||
// task status.
|
||||
func mustWriteDriveDeleteWorkflowFakeCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
bump_counter() {
|
||||
state="$1"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
next=$((count + 1))
|
||||
printf '%s' "$next" > "$state"
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
|
||||
count=0
|
||||
if [ -n "$FAKE_WORKFLOW_DELETE_STATE" ]; then
|
||||
count="$(bump_counter "$FAKE_WORKFLOW_DELETE_STATE")"
|
||||
fi
|
||||
case "$FAKE_WORKFLOW_DELETE_MODE" in
|
||||
sync)
|
||||
echo '{"ok":true,"identity":"bot","data":{"deleted":true,"file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
fail)
|
||||
echo "Deleting docx tok..." >&2
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
|
||||
exit 1
|
||||
;;
|
||||
fail-unexpected)
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"invalid_request","message":"file token not found"}}' >&2
|
||||
exit 1
|
||||
;;
|
||||
async)
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
fail-then-async)
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "unexpected FAKE_WORKFLOW_DELETE_MODE: $FAKE_WORKFLOW_DELETE_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+task_result" ]; then
|
||||
if [ -n "$FAKE_WORKFLOW_TASK_RESULT_STATE" ]; then
|
||||
bump_counter "$FAKE_WORKFLOW_TASK_RESULT_STATE" > /dev/null
|
||||
fi
|
||||
if [ "${FAKE_WORKFLOW_TASK_RESULT_OK:-0}" != "1" ]; then
|
||||
echo "unexpected +task_result call: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","failed":false}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
|
||||
count=0
|
||||
if [ -n "$FAKE_WORKFLOW_META_STATE" ]; then
|
||||
count="$(bump_counter "$FAKE_WORKFLOW_META_STATE")"
|
||||
fi
|
||||
case "$FAKE_WORKFLOW_META_MODE" in
|
||||
gone)
|
||||
echo '{"ok":true,"data":{"metas":[]}}'
|
||||
exit 0
|
||||
;;
|
||||
exists)
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
;;
|
||||
exists-then-gone)
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
fi
|
||||
echo '{"ok":true,"data":{"metas":[]}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "unexpected FAKE_WORKFLOW_META_MODE: $FAKE_WORKFLOW_META_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "unexpected fake CLI args: $*" >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
|
||||
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
|
||||
return binaryPath
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDrive_DeleteAsyncWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
@@ -65,30 +68,117 @@ func createDeleteWorkflowDoc(t *testing.T, ctx context.Context, folderToken, tit
|
||||
return docToken
|
||||
}
|
||||
|
||||
const deleteWorkflowMaxAttempts = 3
|
||||
|
||||
// deleteWorkflowRetryBackoff paces delete retries after a non-retryable
|
||||
// failure whose target still exists. Unit tests shrink it.
|
||||
var deleteWorkflowRetryBackoff = driveDeleteVisibilityPoll
|
||||
|
||||
// deleteAsyncAndVerify deletes token and converges every server outcome to the
|
||||
// real postcondition: the resource is gone. Async deletes (non-empty task_id)
|
||||
// additionally verify the task via drive +task_result; sync deletes (empty
|
||||
// task_id) skip task polling; non-retryable delete failures (e.g. a transient
|
||||
// "drive task failed") pass when the resource is already gone and are retried
|
||||
// up to deleteWorkflowMaxAttempts times otherwise.
|
||||
func deleteAsyncAndVerify(t *testing.T, ctx context.Context, token, docType string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, driveDeleteRetry)
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
var lastResult *clie2e.Result
|
||||
for attempt := 1; attempt <= deleteWorkflowMaxAttempts; attempt++ {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, driveDeleteRetry)
|
||||
require.NoError(t, err)
|
||||
lastResult = result
|
||||
|
||||
taskID := gjson.Get(result.Stdout, "data.task_id").String()
|
||||
require.NotEmpty(t, taskID, "delete must return async task_id\nstdout:\n%s", result.Stdout)
|
||||
if result.ExitCode == 0 {
|
||||
result.AssertStdoutStatus(t, true)
|
||||
taskID := gjson.Get(result.Stdout, "data.task_id").String()
|
||||
if taskID == "" {
|
||||
// Sync completion: the server deleted the resource inline and
|
||||
// returned no task to poll.
|
||||
require.True(t, gjson.Get(result.Stdout, "data.deleted").Bool(), "sync delete must report deleted=true\nstdout:\n%s", result.Stdout)
|
||||
t.Logf("drive +delete completed synchronously for %s %s (no task_id)", docType, token)
|
||||
} else {
|
||||
assertDriveDeleteTaskSucceeded(t, ctx, taskID)
|
||||
}
|
||||
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
|
||||
return taskID
|
||||
}
|
||||
|
||||
// Only the one verified backend transient may fall through to
|
||||
// terminal-state checking; any other failure is a real regression and
|
||||
// must not be rescued by the resource happening to be gone.
|
||||
if !isTransientDriveDeleteFailure(result) {
|
||||
t.Fatalf("drive +delete failed with an unexpected error on attempt %d\nstdout:\n%s\nstderr:\n%s",
|
||||
attempt, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
// The failed delete task may still have removed the resource
|
||||
// server-side, so check the real terminal state before retrying.
|
||||
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, "bot")
|
||||
require.NoError(t, verifyErr, "verify %s %s after failed delete attempt %d", docType, token, attempt)
|
||||
if deleted {
|
||||
t.Logf("drive +delete attempt %d failed transiently but %s %s is gone: stderr=%s", attempt, docType, token, result.Stderr)
|
||||
return ""
|
||||
}
|
||||
if attempt < deleteWorkflowMaxAttempts {
|
||||
t.Logf("drive +delete attempt %d failed and %s %s still exists; retrying: stderr=%s", attempt, docType, token, result.Stderr)
|
||||
time.Sleep(deleteWorkflowRetryBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("drive +delete failed %d times and %s %s still exists\nstdout:\n%s\nstderr:\n%s",
|
||||
deleteWorkflowMaxAttempts, docType, token, lastResult.Stdout, lastResult.Stderr)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertDriveDeleteTaskSucceeded(t *testing.T, ctx context.Context, taskID string) {
|
||||
t.Helper()
|
||||
|
||||
taskResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+task_result", "--scenario", "task_check", "--task-id", taskID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
taskResult.AssertExitCode(t, 0)
|
||||
require.NotNil(t, taskResult)
|
||||
// Fatal exit-code gate first: the non-fatal assert flavor would cascade
|
||||
// into misleading empty-stdout failures, exactly what this fix removes.
|
||||
require.Equal(t, 0, taskResult.ExitCode, "drive +task_result failed\nstdout:\n%s\nstderr:\n%s", taskResult.Stdout, taskResult.Stderr)
|
||||
taskResult.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, taskID, gjson.Get(taskResult.Stdout, "data.task_id").String(), "stdout:\n%s", taskResult.Stdout)
|
||||
require.False(t, gjson.Get(taskResult.Stdout, "data.failed").Bool(), "stdout:\n%s", taskResult.Stdout)
|
||||
|
||||
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
|
||||
return taskID
|
||||
// gjson returns false for an absent field too, so require presence or a
|
||||
// malformed task envelope would pass validation.
|
||||
failedField := gjson.Get(taskResult.Stdout, "data.failed")
|
||||
require.True(t, failedField.Exists(), "task result must report data.failed\nstdout:\n%s", taskResult.Stdout)
|
||||
require.False(t, failedField.Bool(), "stdout:\n%s", taskResult.Stdout)
|
||||
}
|
||||
|
||||
// isTransientDriveDeleteFailure reports whether a failed drive +delete carries
|
||||
// the one backend error this workflow tolerates: the async delete task
|
||||
// transiently reporting a terminal "fail" state (observed as flake in CI; the
|
||||
// resource is usually deleted regardless). Everything else — crashes, protocol
|
||||
// regressions, auth or parameter errors — stays fatal.
|
||||
func isTransientDriveDeleteFailure(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range []string{result.Stderr, result.Stdout} {
|
||||
idx := strings.Index(raw, "{")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
payload := raw[idx:]
|
||||
if !gjson.Valid(payload) {
|
||||
continue
|
||||
}
|
||||
errObj := gjson.Get(payload, "error")
|
||||
if errObj.Get("type").String() == "api" &&
|
||||
errObj.Get("subtype").String() == "server_error" &&
|
||||
errObj.Get("message").String() == "drive task failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
|
||||
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user