mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc6bb67aa | ||
|
|
a1438586ec | ||
|
|
c9b660ae12 | ||
|
|
567b40778b | ||
|
|
ec23995bce | ||
|
|
1980b999f7 | ||
|
|
1be9a241b7 | ||
|
|
f4afa47de8 | ||
|
|
bb38ecd41a | ||
|
|
9f0758bfef | ||
|
|
d3d92e37c2 | ||
|
|
b064188f20 | ||
|
|
799179fde6 | ||
|
|
8db4528269 | ||
|
|
30dba35c77 | ||
|
|
2efadece34 | ||
|
|
b7613d64bd | ||
|
|
0c77c95a11 | ||
|
|
135fde8b6d | ||
|
|
5cf866739d | ||
|
|
77460abc49 | ||
|
|
a641fdd5e6 | ||
|
|
8645d26d09 | ||
|
|
b5b23fe82a | ||
|
|
84258980c6 | ||
|
|
51a6adab2b | ||
|
|
9e367b4736 |
135
.github/workflows/cli-e2e.yml
vendored
Normal file
135
.github/workflows/cli-e2e.yml
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
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"
|
||||
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
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
6
.github/workflows/coverage.yml
vendored
6
.github/workflows/coverage.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
@@ -12,6 +13,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
@@ -37,7 +39,9 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
|
||||
2
.github/workflows/gitleaks.yml
vendored
2
.github/workflows/gitleaks.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
57
.github/workflows/issue-labels.yml
vendored
Normal file
57
.github/workflows/issue-labels.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Issue Labels
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Do not write labels, only print planned changes"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-issue-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# v6+ uses Node 24 runtime for JavaScript actions.
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Sync managed issue labels
|
||||
id: sync_issue_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
args=(
|
||||
"--max-issues" "300"
|
||||
)
|
||||
|
||||
# Schedule runs should write labels by default.
|
||||
# Manual runs default to dry-run unless explicitly disabled.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
|
||||
args+=("--dry-run" "--json")
|
||||
fi
|
||||
|
||||
node scripts/issue-labels/index.js "${args[@]}"
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::Issue label sync failed; labels may be stale."
|
||||
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -27,6 +27,7 @@ linters:
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- forbidigo # forbids specific function calls
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
@@ -44,8 +45,89 @@ linters:
|
||||
linters:
|
||||
- bodyclose
|
||||
- gocritic
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── Filesystem operations: use internal/vfs instead ──
|
||||
- pattern: os\.Stat\b
|
||||
msg: "use vfs.Stat() from internal/vfs"
|
||||
- pattern: os\.Lstat\b
|
||||
msg: "use vfs.Lstat() from internal/vfs"
|
||||
- pattern: os\.Open\b
|
||||
msg: "use vfs.Open() from internal/vfs"
|
||||
- pattern: os\.OpenFile\b
|
||||
msg: "use vfs.OpenFile() from internal/vfs"
|
||||
- pattern: os\.Create\b
|
||||
msg: "use vfs.OpenFile() from internal/vfs"
|
||||
- pattern: os\.CreateTemp\b
|
||||
msg: >-
|
||||
internal/: use vfs.CreateTemp() from internal/vfs.
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.Mkdir\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.MkdirAll\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.Remove\b
|
||||
msg: >-
|
||||
internal/: use vfs.Remove() from internal/vfs.
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.RemoveAll\b
|
||||
msg: >-
|
||||
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.Rename\b
|
||||
msg: "use vfs.Rename() from internal/vfs"
|
||||
- pattern: os\.ReadFile\b
|
||||
msg: "use vfs.ReadFile() from internal/vfs"
|
||||
- pattern: os\.WriteFile\b
|
||||
msg: "use vfs.WriteFile() from internal/vfs"
|
||||
- pattern: os\.ReadDir\b
|
||||
msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()"
|
||||
- pattern: os\.Getwd\b
|
||||
msg: "use vfs.Getwd() from internal/vfs"
|
||||
- pattern: os\.Chdir\b
|
||||
msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()"
|
||||
- pattern: os\.UserHomeDir\b
|
||||
msg: "use vfs.UserHomeDir() from internal/vfs"
|
||||
- pattern: os\.Chmod\b
|
||||
msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()"
|
||||
- pattern: os\.Chown\b
|
||||
msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()"
|
||||
- pattern: os\.Lchown\b
|
||||
msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()"
|
||||
- pattern: os\.Link\b
|
||||
msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()"
|
||||
- pattern: os\.Symlink\b
|
||||
msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()"
|
||||
- pattern: os\.Readlink\b
|
||||
msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()"
|
||||
- pattern: os\.Truncate\b
|
||||
msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()"
|
||||
- pattern: os\.DirFS\b
|
||||
msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()"
|
||||
- pattern: os\.SameFile\b
|
||||
msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()"
|
||||
# ── IO streams: use IOStreams from cmdutil instead ──
|
||||
- pattern: os\.Stdin\b
|
||||
msg: "use IOStreams.In instead of os.Stdin"
|
||||
- pattern: os\.Stdout\b
|
||||
msg: "use IOStreams.Out instead of os.Stdout"
|
||||
- pattern: os\.Stderr\b
|
||||
msg: "use IOStreams.ErrOut instead of os.Stderr"
|
||||
# ── Process-level rules ──
|
||||
- pattern: os\.Exit\b
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
|
||||
81
AGENTS.md
81
AGENTS.md
@@ -1,33 +1,78 @@
|
||||
# AGENTS.md
|
||||
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
|
||||
|
||||
## Goal (pick one per PR)
|
||||
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Fast Dev Loop
|
||||
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
|
||||
2. `make unit-test` (required before PR)
|
||||
3. Run changed command(s) manually via `./lark-cli ...`
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
5. Optional full local suite: `make test` (vet + unit + integration)
|
||||
|
||||
## Test/Check Commands
|
||||
- Unit: `make unit-test`
|
||||
- Integration: `make integration-test`
|
||||
- Full: `make test`
|
||||
- Vet only: `make vet`
|
||||
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
|
||||
## Commit & PR
|
||||
|
||||
## Commit/PR Rules
|
||||
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
|
||||
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
|
||||
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
|
||||
- Never commit secrets/tokens/internal sensitive data.
|
||||
- Conventional Commits in English: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:`
|
||||
- PR title in the same format. Fill `.github/pull_request_template.md` completely.
|
||||
- Never commit secrets, tokens, or internal sensitive data.
|
||||
|
||||
## Source Layout
|
||||
|
||||
| Path | What it does |
|
||||
|------|-------------|
|
||||
| `cmd/root.go` | Entry point, command registration, strict mode pruning |
|
||||
| `cmd/profile/` | Multi-profile management (add/list/use/rename/remove) |
|
||||
| `cmd/config/` | Config init, show, strict-mode |
|
||||
| `cmd/service/` | Auto-registered API commands from embedded metadata |
|
||||
| `shortcuts/common/runner.go` | Shortcut execution pipeline, Flag.Input (@file/stdin) resolution |
|
||||
| `shortcuts/` | Domain-specific shortcut implementations |
|
||||
| `internal/cmdutil/factory.go` | Factory pattern — identity resolution, credential, config |
|
||||
| `internal/cmdutil/factory_default.go` | Production factory wiring |
|
||||
| `internal/credential/` | Credential provider chain (extension → default) |
|
||||
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
|
||||
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
|
||||
| `internal/core/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
## Who Uses This CLI
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
|
||||
|
||||
The one rule to internalize: **every error message you write will be parsed by an AI to decide its next action.** Make errors structured, actionable, and specific.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
|
||||
### Validate paths before reading
|
||||
|
||||
CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInputPath` before any file I/O.
|
||||
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -2,6 +2,63 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Support multipart upload for files larger than 20MB (#43)
|
||||
- Add darwin file master key fallback for keychain writes (#285)
|
||||
- Add strict mode identity filter, profile management and credential extension (#252)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Restore CID validation and stale PartID lookup lost in revert (#230)
|
||||
- **base**: Clarify table-id `tbl` prefix requirement (#270)
|
||||
- Fix parameter constraints for LarkMessageTrigger (#213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix root calendar example (#299)
|
||||
- Fix README auth scope and api data flag (#298)
|
||||
- Clarify task guid for applinks (#287)
|
||||
- Clarify lark task guid usage (#282)
|
||||
- **lark-base**: Add `has_more` guidance for record-list pagination (#183)
|
||||
|
||||
### Tests
|
||||
|
||||
- Isolate registry package state in tests (#280)
|
||||
|
||||
### CI
|
||||
|
||||
- Add scheduled issue labeler for type/domain triage (#251)
|
||||
- **issue-labels**: Reduce mislabeling and handle missing labels (#288)
|
||||
- Map wiki paths in pr labels (#249)
|
||||
|
||||
## [v1.0.4] - 2026-04-03
|
||||
|
||||
### Features
|
||||
|
||||
- Support user identity for im `+chat-create` (#242)
|
||||
- Implement authentication response logging (#235)
|
||||
- Support im chat member delete and add scope notes (#229)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **security**: Replace `http.DefaultTransport` with proxy-aware base transport to mitigate MITM risk (#247)
|
||||
- **calendar**: Block auto bot fallback without user login (#245)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add identity guidance to prefer user over bot (#157)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **dashboard**: Restructure docs for AI-friendly navigation (#191)
|
||||
|
||||
### CI
|
||||
|
||||
- Add a CLI E2E testing framework for lark-cli, task domain testcase and ci action (#236)
|
||||
|
||||
## [v1.0.3] - 2026-04-02
|
||||
|
||||
### Features
|
||||
@@ -136,6 +193,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
|
||||
@@ -173,7 +173,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# Exact scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent mode: return verification URL immediately, non-blocking
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -216,7 +216,7 @@ Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
@@ -174,7 +174,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# 精确 scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent 模式:立即返回验证 URL,不阻塞
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -217,7 +217,7 @@ lark-cli calendar events instance_view --params '{"calendar_id":"primary","start
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## 进阶用法
|
||||
|
||||
@@ -152,7 +152,11 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
@@ -166,7 +170,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -70,16 +70,6 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-test-token",
|
||||
"expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register API endpoint stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
@@ -234,13 +224,6 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-bin", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/drive/v1/files/xxx/download",
|
||||
RawBody: []byte("fake-binary-content"),
|
||||
@@ -266,14 +249,6 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a non-batch API that returns scalar data (no array field)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
@@ -310,13 +285,6 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-err", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Non-batch API that returns a business error (code != 0)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
@@ -346,14 +314,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub (unique app credentials => new token request)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a batch API that returns an array field
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
@@ -409,13 +369,6 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-raw", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
@@ -456,13 +409,6 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
@@ -505,13 +451,6 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
@@ -599,13 +538,6 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-jq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
@@ -676,13 +608,6 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
@@ -48,7 +49,7 @@ type userInfoResponse struct {
|
||||
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/authen/v1/user_info",
|
||||
ApiPath: larkauth.PathUserInfoV1,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
||||
}, larkcore.WithUserAccessToken(accessToken))
|
||||
if err != nil {
|
||||
@@ -109,7 +110,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/application/v6/applications/" + appId,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ func authListRun(opts *ListOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,6 +59,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
return authLoginRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
@@ -101,8 +108,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
lang := "zh"
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
|
||||
lang = multi.Apps[0].Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
}
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
@@ -304,18 +313,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
@@ -384,24 +384,49 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return fmt.Errorf("profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
if oldUser.UserOpenId != openID {
|
||||
_ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
|
||||
74
cmd/auth/login_config_test.go
Normal file
74
cmd/auth/login_config_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" {
|
||||
t.Fatalf("target users = %#v, want replaced login user", got)
|
||||
}
|
||||
if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" {
|
||||
t.Fatalf("other users = %#v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing profile")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `profile "missing" not found`) {
|
||||
t.Fatalf("error = %v, want missing profile", err)
|
||||
}
|
||||
}
|
||||
78
cmd/auth/login_strict_test.go
Normal file
78
cmd/auth/login_strict_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if called {
|
||||
t.Error("runF should not be called in bot strict mode")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error in bot strict mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called in user strict mode")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called when strict mode is off")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := &multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
30
cmd/bootstrap.go
Normal file
30
cmd/bootstrap.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BootstrapInvocationContext extracts global invocation options before
|
||||
// the real command tree is built, so provider-backed config resolution sees
|
||||
// the correct profile from the start.
|
||||
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
||||
var globals GlobalOptions
|
||||
|
||||
fs := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.SetInterspersed(true)
|
||||
fs.SetOutput(io.Discard)
|
||||
RegisterGlobalFlags(fs, &globals)
|
||||
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
72
cmd/bootstrap_test.go
Normal file
72
cmd/bootstrap_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileEquals(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--profile=target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_IgnoresUnknownFlags(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--verify", "--profile", "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_MissingProfileValue(t *testing.T) {
|
||||
if _, err := BootstrapInvocationContext([]string{"auth", "status", "--profile"}); err == nil {
|
||||
t.Fatal("BootstrapInvocationContext() error = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ShortHelp(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("-h should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--profile + --help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
if value == "lark" {
|
||||
return core.BrandLark
|
||||
}
|
||||
return core.BrandFeishu
|
||||
return core.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,22 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
|
||||
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -56,6 +65,60 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -157,3 +220,50 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
t.Fatal("expected factory to be preserved in options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Lang: "zh",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "App Secret") {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
current := multi.Apps[0].DefaultAs
|
||||
current := app.DefaultAs
|
||||
if current == "" {
|
||||
current = "auto"
|
||||
}
|
||||
@@ -39,9 +44,9 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
multi.Apps[0].DefaultAs = value
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,8 @@ type ConfigInitOptions struct {
|
||||
Brand string
|
||||
New bool
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -59,6 +62,7 @@ verification URL from its output.`,
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -94,6 +98,110 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
return core.SaveMultiAppConfig(config)
|
||||
}
|
||||
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
|
||||
// Clean up old keychain secret and user tokens if AppId changed
|
||||
if multi.Apps[idx].AppId != appId {
|
||||
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
for _, user := range multi.Apps[idx].Users {
|
||||
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = lang
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].Name == profileName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].AppId == appID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = lang
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -117,6 +225,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -124,8 +239,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, opts.AppID)
|
||||
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -136,8 +250,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
savedLang := ""
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
savedLang = existing.Apps[0].Lang
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
if err != nil {
|
||||
@@ -165,8 +281,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -191,21 +306,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
existing.Apps[0].AppId = result.AppID
|
||||
existing.Apps[0].Brand = result.Brand
|
||||
existing.Apps[0].Lang = opts.Lang
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
@@ -224,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(f.IOStreams.In)
|
||||
@@ -296,8 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, resolvedAppId)
|
||||
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -61,8 +61,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
// Load existing config for defaults
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
var appID, appSecret, brand string
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -40,12 +42,19 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
|
||||
return nil
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
}
|
||||
app := config.Apps[0]
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
var userStrs []string
|
||||
@@ -55,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
"brand": app.Brand,
|
||||
|
||||
146
cmd/config/strict_mode.go
Normal file
146
cmd/config/strict_mode.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
|
||||
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
var global bool
|
||||
var reset bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
|
||||
// Runtime effective mode from credential provider chain is the source of truth.
|
||||
runtime := f.ResolveStrictMode(ctx)
|
||||
configMode, configSource := resolveStrictModeStatus(multi, app)
|
||||
|
||||
if runtime != configMode {
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
if a.StrictMode != nil && *a.StrictMode != mode {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut,
|
||||
"Warning: profile %q has strict-mode explicitly set to %q, "+
|
||||
"which overrides the global setting. "+
|
||||
"Use --reset in that profile to inherit global.\n",
|
||||
a.ProfileName(), *a.StrictMode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
}
|
||||
if multi.StrictMode.IsActive() {
|
||||
return multi.StrictMode, "global"
|
||||
}
|
||||
return core.StrictModeOff, "global (default)"
|
||||
}
|
||||
164
cmd/config/strict_mode_test.go
Normal file
164
cmd/config/strict_mode_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Show_Default(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "off") {
|
||||
t.Errorf("expected 'off' in output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected StrictMode=bot on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetUser_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"user"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
|
||||
t.Error("expected StrictMode=user on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
|
||||
t.Error("expected StrictMode=off on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != core.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Reset(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"--reset"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid value 'on'")
|
||||
}
|
||||
}
|
||||
17
cmd/global_flags.go
Normal file
17
cmd/global_flags.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
}
|
||||
137
cmd/profile/add.go
Normal file
137
cmd/profile/add.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
appID string
|
||||
appSecretStdin bool
|
||||
brand string
|
||||
lang string
|
||||
use bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileAddRun(f, name, appID, appSecretStdin, brand, lang, use)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "profile name (required)")
|
||||
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
||||
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
||||
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return output.ErrValidation("profile %q already exists", name)
|
||||
}
|
||||
|
||||
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||
// multiple profiles sharing the same appId would collide on credentials.
|
||||
for _, a := range multi.Apps {
|
||||
if a.AppId == appID {
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
if useAfter {
|
||||
if currentApp := multi.CurrentAppConfig(""); currentApp != nil {
|
||||
previousName = currentApp.ProfileName()
|
||||
}
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
if previousName != "" {
|
||||
multi.PreviousApp = previousName
|
||||
}
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
if useAfter {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q", name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
cmd/profile/list.go
Normal file
85
cmd/profile/list.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
currentName = currentApp.ProfileName()
|
||||
}
|
||||
|
||||
items := make([]profileListItem, 0, len(multi.Apps))
|
||||
for i := range multi.Apps {
|
||||
app := &multi.Apps[i]
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
item.User = app.Users[0].UserName
|
||||
stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId)
|
||||
if stored != nil {
|
||||
item.TokenStatus = larkauth.TokenStatus(stored)
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, items)
|
||||
return nil
|
||||
}
|
||||
29
cmd/profile/profile.go
Normal file
29
cmd/profile/profile.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
|
||||
})
|
||||
|
||||
cmd.AddCommand(NewCmdProfileList(f))
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
cmd.AddCommand(NewCmdProfileRename(f))
|
||||
return cmd
|
||||
}
|
||||
371
cmd/profile/profile_test.go
Normal file
371
cmd/profile/profile_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type failRenameFS struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
}
|
||||
|
||||
func (fs *failRenameFS) Rename(oldpath, newpath string) error {
|
||||
return fs.err
|
||||
}
|
||||
|
||||
func setupProfileConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
|
||||
err := profileAddRun(f, "test", "app-test", true, "feishu", "zh", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid existing config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to load config") {
|
||||
t.Fatalf("error = %v, want failed to load config", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret-new\n")
|
||||
|
||||
if err := profileAddRun(f, "target", "app-target", true, "lark", "en", true); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 2 {
|
||||
t.Fatalf("len(Apps) = %d, want 2", len(saved.Apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
PreviousApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRemoveRun(f, "target"); err != nil {
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "default" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" {
|
||||
t.Fatalf("remaining apps = %#v, want only default", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "new"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "new" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "new")
|
||||
}
|
||||
if saved.PreviousApp != "new" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "new")
|
||||
}
|
||||
if saved.Apps[0].ProfileName() != "new" {
|
||||
t.Fatalf("ProfileName() = %q, want %q", saved.Apps[0].ProfileName(), "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "app-old"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "app-old" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "app-old")
|
||||
}
|
||||
if saved.PreviousApp != "app-old" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "app-old")
|
||||
}
|
||||
if saved.Apps[0].Name != "app-old" {
|
||||
t.Fatalf("Name = %q, want %q", saved.Apps[0].Name, "app-old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
PreviousApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileUseRun(f, "-"); err != nil {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len(got) = %d, want 0", len(got))
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "old", "new")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
}
|
||||
}
|
||||
78
cmd/profile/remove.go
Normal file
78
cmd/profile/remove.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRemoveRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
removedName := app.ProfileName()
|
||||
appId := app.AppId
|
||||
appSecret := app.AppSecret
|
||||
users := app.Users
|
||||
|
||||
// Remove from slice
|
||||
multi.Apps = append(multi.Apps[:idx], multi.Apps[idx+1:]...)
|
||||
|
||||
// Fix currentApp / previousApp references
|
||||
if multi.CurrentApp == removedName {
|
||||
multi.CurrentApp = multi.Apps[0].ProfileName()
|
||||
}
|
||||
if multi.PreviousApp == removedName {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/rename.go
Normal file
73
cmd/profile/rename.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRename creates the profile rename subcommand.
|
||||
func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename <old> <new>",
|
||||
Short: "Rename a profile",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
// profile's own appId or current name.
|
||||
for i := range multi.Apps {
|
||||
if i == idx {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
oldProfileName := multi.Apps[idx].ProfileName()
|
||||
multi.Apps[idx].Name = newName
|
||||
|
||||
// Update currentApp / previousApp references
|
||||
if multi.CurrentApp == oldProfileName {
|
||||
multi.CurrentApp = newName
|
||||
}
|
||||
if multi.PreviousApp == oldProfileName {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/use.go
Normal file
73
cmd/profile/use.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileUse creates the profile use subcommand.
|
||||
func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Switch to a profile (use '-' to toggle back)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileUseRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
|
||||
// Short-circuit if already on the target profile
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
if currentApp != nil && currentApp.ProfileName() == targetName {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Already on profile %q\n", targetName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update previous and current
|
||||
if currentApp != nil {
|
||||
multi.PreviousApp = currentApp.ProfileName()
|
||||
}
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||
return nil
|
||||
}
|
||||
80
cmd/prune.go
Normal file
80
cmd/prune.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
|
||||
// pruneIncompatible recursively replaces commands whose annotation declares
|
||||
// identities incompatible with the forced identity. Commands without annotation are kept.
|
||||
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
||||
// of Cobra's generic "unknown flag" fallback from the parent command.
|
||||
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
ids := cmdutil.GetSupportedIdentities(child)
|
||||
if ids != nil && !slices.Contains(ids, forced) {
|
||||
toRemove = append(toRemove, child)
|
||||
toAdd = append(toAdd, strictModeStubFrom(child, mode))
|
||||
continue
|
||||
}
|
||||
pruneIncompatible(child, mode)
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
parent.AddCommand(toAdd...)
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmpty recursively removes group commands (no Run/RunE) that have
|
||||
// no remaining subcommands after pruning. If only hidden stubs remain, keep
|
||||
// the group hidden so direct execution still resolves to the stub path.
|
||||
func pruneEmpty(parent *cobra.Command) {
|
||||
var toRemove []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
pruneEmpty(child)
|
||||
if child.Run != nil || child.RunE != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case child.HasAvailableSubCommands():
|
||||
case len(child.Commands()) > 0:
|
||||
child.Hidden = true
|
||||
default:
|
||||
toRemove = append(toRemove, child)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
}
|
||||
}
|
||||
200
cmd/prune_test.go
Normal file
200
cmd/prune_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
|
||||
noop := func(*cobra.Command, []string) error { return nil }
|
||||
|
||||
userOnly := &cobra.Command{Use: "+search", Short: "user only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
botOnly := &cobra.Command{Use: "+subscribe", Short: "bot only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
|
||||
svc.AddCommand(botOnly)
|
||||
|
||||
dual := &cobra.Command{Use: "+send", Short: "dual", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(dual, []string{"user", "bot"})
|
||||
svc.AddCommand(dual)
|
||||
|
||||
noAnnotation := &cobra.Command{Use: "+legacy", Short: "no annotation", RunE: noop}
|
||||
svc.AddCommand(noAnnotation)
|
||||
|
||||
res := &cobra.Command{Use: "messages"}
|
||||
svc.AddCommand(res)
|
||||
userMethod := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmdutil.SetSupportedIdentities(userMethod, []string{"user"})
|
||||
res.AddCommand(userMethod)
|
||||
|
||||
auth := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(auth)
|
||||
login := &cobra.Command{Use: "login", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(login, []string{"user"})
|
||||
auth.AddCommand(login)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func findCmd(root *cobra.Command, names ...string) *cobra.Command {
|
||||
cmd := root
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
cmd = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+subscribe") == nil {
|
||||
t.Error("+subscribe (bot-only) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+legacy") == nil {
|
||||
t.Error("+legacy (no annotation) should be kept")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "messages", "search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("search (user-only method) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("auth login should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
|
||||
if findCmd(root, "im", "+search") == nil {
|
||||
t.Error("+search (user-only) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "+subscribe"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+subscribe (bot-only) should be replaced by a hidden stub in user mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || cmd.Hidden {
|
||||
t.Error("auth login should be kept in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty_PreservesOriginallyHiddenGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
hidden := &cobra.Command{Use: "hidden", Hidden: true}
|
||||
root.AddCommand(hidden)
|
||||
hidden.AddCommand(&cobra.Command{
|
||||
Use: "visible",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
})
|
||||
|
||||
pruneEmpty(root)
|
||||
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("expected originally hidden group to remain hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "+search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "user"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
20
cmd/root.go
20
cmd/root.go
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
@@ -43,7 +45,7 @@ EXAMPLES:
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events list --params '{"calendar_id":"primary"}'
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
@@ -87,8 +89,14 @@ More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
f := cmdutil.NewDefault()
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -97,12 +105,15 @@ func Execute() int {
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
@@ -110,6 +121,11 @@ func Execute() int {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildTestRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeE2E runs a command through the full command tree and handleRootError,
|
||||
// returning exit code — matching real CLI behavior.
|
||||
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
|
||||
func registerTokenStub(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-e2e-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
490
cmd/root_integration_test.go
Normal file
490
cmd/root_integration_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeRootIntegration runs a command through the full command tree and
|
||||
// handleRootError, returning the exit code matching real CLI behavior.
|
||||
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
targetMode := mode
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
StrictMode: &targetMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("auth --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "login") {
|
||||
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
resetBuffers(stdout, stderr)
|
||||
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
|
||||
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("im --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "+messages-search") {
|
||||
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+chat-create") {
|
||||
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
||||
// +chat-create supports both user and bot identities, so strict mode user
|
||||
// should allow it and force user identity.
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty stdout for dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -169,13 +170,20 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
@@ -191,7 +199,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +208,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -247,25 +255,30 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
result, err := cred.ResolveToken(ctx, credential.NewTokenSpec(identity, config.AppID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored != nil {
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,16 +287,12 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// Default: ANY one of the declared scopes is sufficient
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
grantedScopes := make(map[string]bool)
|
||||
for _, s := range strings.Fields(stored.Scope) {
|
||||
grantedScopes[s] = true
|
||||
grantedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedScopes[str] {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
return m
|
||||
}
|
||||
|
||||
func tokenStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: "tenant_access_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test", "expire": 7200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
|
||||
func TestRegisterService(t *testing.T) {
|
||||
@@ -364,7 +354,6 @@ func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -391,7 +380,6 @@ func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
@@ -425,7 +413,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -455,7 +442,6 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
@@ -540,7 +526,6 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -612,7 +597,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
|
||||
116
extension/credential/env/env.go
vendored
Normal file
116
extension/credential/env/env.go
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Provider resolves credentials from environment variables.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "env" }
|
||||
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
acct.DefaultAs = credential.IdentityUser
|
||||
case hasTAT:
|
||||
acct.DefaultAs = credential.IdentityBot
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
envKey = envvars.CliUserAccessToken
|
||||
case credential.TokenTypeTAT:
|
||||
envKey = envvars.CliTenantAccessToken
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
token := os.Getenv(envKey)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
279
extension/credential/env/env_test.go
vendored
Normal file
279
extension/credential/env/env_test.go
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
if (&Provider{}).Name() != "env" {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, "feishu")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_NeitherSet(t *testing.T) {
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil || acct != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected account, got nil")
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
|
||||
}
|
||||
if acct.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultBrand(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
acct, _ := (&Provider{}).ResolveAccount(context.Background())
|
||||
if acct.Brand != "feishu" {
|
||||
t.Errorf("expected 'feishu', got %q", acct.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_UATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_TATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotSet(t *testing.T) {
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil || tok != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeBot(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeUser(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOff(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "off")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "bot" {
|
||||
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferBothTokens(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid strict mode")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid default-as")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
29
extension/credential/registry.go
Normal file
29
extension/credential/registry.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
providers []Provider
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
func Providers() []Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]Provider, len(providers))
|
||||
copy(result, providers)
|
||||
return result
|
||||
}
|
||||
51
extension/credential/registry_test.go
Normal file
51
extension/credential/registry_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ name string }
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return &Account{AppID: s.name}, nil
|
||||
}
|
||||
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
|
||||
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
|
||||
}
|
||||
|
||||
func TestRegisterAndProviders(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "a"})
|
||||
Register(&stubProvider{name: "b"})
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "a" || got[1].Name() != "b" {
|
||||
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "x"})
|
||||
snap := Providers()
|
||||
Register(&stubProvider{name: "y"})
|
||||
|
||||
if len(snap) != 1 {
|
||||
t.Fatalf("snapshot should not be affected, got %d", len(snap))
|
||||
}
|
||||
}
|
||||
100
extension/credential/types.go
Normal file
100
extension/credential/types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "context"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandLark Brand = "lark"
|
||||
BrandFeishu Brand = "feishu"
|
||||
)
|
||||
|
||||
// NoAppSecret marks that a credential source does not provide a real app secret.
|
||||
// Token-only sources should return this value instead of inventing placeholder text.
|
||||
const NoAppSecret = ""
|
||||
|
||||
// Identity represents the caller identity type.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
IdentityAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IdentitySupport declares which identities a credential source can provide.
|
||||
type IdentitySupport uint8
|
||||
|
||||
const (
|
||||
SupportsUser IdentitySupport = 1 << iota
|
||||
SupportsBot
|
||||
SupportsAll = SupportsUser | SupportsBot
|
||||
)
|
||||
|
||||
// Has reports whether s includes the given flag.
|
||||
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
|
||||
|
||||
// UserOnly returns true if only user identity is supported.
|
||||
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
type Token struct {
|
||||
Value string
|
||||
Scopes string // space-separated; empty = skip scope pre-check
|
||||
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
|
||||
}
|
||||
|
||||
// TokenType represents the kind of access token.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat"
|
||||
TokenTypeTAT TokenType = "tat"
|
||||
)
|
||||
|
||||
// TokenSpec describes what token is needed.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
return "blocked by " + e.Provider + ": " + e.Reason
|
||||
}
|
||||
|
||||
// Provider is the unified interface for credential resolution.
|
||||
//
|
||||
// Flow control uses Go's native mechanisms:
|
||||
// - Handle: return &Account{...}, nil or return &Token{...}, nil
|
||||
// - Skip: return nil, nil
|
||||
// - Block: return nil, &BlockError{...}
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
|
||||
}
|
||||
39
extension/credential/types_test.go
Normal file
39
extension/credential/types_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySupport_Has(t *testing.T) {
|
||||
if !SupportsAll.Has(SupportsUser) {
|
||||
t.Error("SupportsAll should have SupportsUser")
|
||||
}
|
||||
if !SupportsAll.Has(SupportsBot) {
|
||||
t.Error("SupportsAll should have SupportsBot")
|
||||
}
|
||||
if SupportsUser.Has(SupportsBot) {
|
||||
t.Error("SupportsUser should not have SupportsBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_UserOnly(t *testing.T) {
|
||||
if !SupportsUser.UserOnly() {
|
||||
t.Error("SupportsUser.UserOnly() should be true")
|
||||
}
|
||||
if SupportsAll.UserOnly() {
|
||||
t.Error("SupportsAll.UserOnly() should be false")
|
||||
}
|
||||
if IdentitySupport(0).UserOnly() {
|
||||
t.Error("zero value UserOnly() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_BotOnly(t *testing.T) {
|
||||
if !SupportsBot.BotOnly() {
|
||||
t.Error("SupportsBot.BotOnly() should be true")
|
||||
}
|
||||
if SupportsAll.BotOnly() {
|
||||
t.Error("SupportsAll.BotOnly() should be false")
|
||||
}
|
||||
}
|
||||
28
extension/transport/registry.go
Normal file
28
extension/transport/registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a transport Provider.
|
||||
// Later registrations override earlier ones.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
77
extension/transport/registry_test.go
Normal file
77
extension/transport/registry_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubInterceptor struct{}
|
||||
|
||||
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
|
||||
|
||||
func TestGetProvider_NilByDefault(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
if got := GetProvider(); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndGet(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "a"}
|
||||
Register(p)
|
||||
|
||||
got := GetProvider()
|
||||
if got != p {
|
||||
t.Fatalf("expected registered provider, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastRegistrationWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
a := &stubProvider{name: "a"}
|
||||
b := &stubProvider{name: "b"}
|
||||
Register(a)
|
||||
Register(b)
|
||||
|
||||
got := GetProvider()
|
||||
if got != b {
|
||||
t.Fatalf("expected provider b, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "test"}
|
||||
Register(p)
|
||||
|
||||
ic := GetProvider().ResolveInterceptor(context.Background())
|
||||
if ic == nil {
|
||||
t.Fatal("expected non-nil Interceptor")
|
||||
}
|
||||
}
|
||||
32
extension/transport/types.go
Normal file
32
extension/transport/types.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider creates Interceptor instances.
|
||||
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
//
|
||||
// PreRoundTrip is called before the built-in chain. Use it to add custom
|
||||
// headers, rewrite the host, or start trace spans. Built-in decorators run
|
||||
// after this and will override any same-named security headers set here.
|
||||
// The extension must not replace req.Context() — the middleware restores
|
||||
// the original context after PreRoundTrip returns.
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
9
go.mod
9
go.mod
@@ -12,6 +12,9 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.33.0
|
||||
@@ -31,6 +34,7 @@ require (
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
@@ -48,9 +52,12 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@@ -107,6 +107,12 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -47,7 +47,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + "/oauth/v1/app/registration"
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
@@ -66,6 +66,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -129,7 +130,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
const maxPollAttempts = 200
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + "/oauth/v1/app/registration"
|
||||
endpoint := ep.Accounts + PathAppRegistration
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
currentInterval := interval
|
||||
attempts := 0
|
||||
@@ -162,6 +163,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
|
||||
func Test_BuildVerificationURL(t *testing.T) {
|
||||
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
|
||||
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
|
||||
|
||||
38
internal/auth/auth_response_log.go
Normal file
38
internal/auth/auth_response_log.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// logHTTPResponse logs the HTTP response details for an authentication request.
|
||||
// It extracts the request path, status code, and x-tt-logid from the given HTTP response.
|
||||
func logHTTPResponse(resp *http.Response) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := "missing"
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
path = resp.Request.URL.Path
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
// logSDKResponse logs the SDK response details for an authentication request.
|
||||
// It extracts the status code and x-tt-logid from the given API response object.
|
||||
func logSDKResponse(path string, apiResp *larkcore.ApiResp) {
|
||||
if path == "" {
|
||||
path = "missing"
|
||||
}
|
||||
|
||||
if apiResp == nil {
|
||||
keychain.LogAuthResponse(path, 0, "")
|
||||
return
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
@@ -54,8 +54,8 @@ type OAuthEndpoints struct {
|
||||
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization",
|
||||
Token: ep.Open + "/open-apis/authen/v2/oauth/token",
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -179,6 +180,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
@@ -258,6 +260,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
|
||||
// helpers
|
||||
|
||||
// minInt returns the smaller of a or b.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -265,6 +268,7 @@ func minInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// getStr retrieves a string value from a map, returning an empty string if not found or not a string.
|
||||
func getStr(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
@@ -274,6 +278,7 @@ func getStr(m map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number.
|
||||
func getInt(m map[string]interface{}, key string, fallback int) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
|
||||
@@ -4,11 +4,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
@@ -19,6 +28,7 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
|
||||
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandLark)
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
@@ -28,3 +38,137 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly.
|
||||
func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Tt-Logid": []string{"device-log-id"},
|
||||
},
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "time=2026-04-02T03:04:05Z") {
|
||||
t.Fatalf("expected time in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "path=missing") {
|
||||
t.Fatalf("expected path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=200") {
|
||||
t.Fatalf("expected status=200 in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "x-tt-logid=device-log-id") {
|
||||
t.Fatalf("expected x-tt-logid in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
|
||||
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
|
||||
got := keychain.FormatAuthCmdline([]string{
|
||||
"lark-cli",
|
||||
"auth",
|
||||
"login",
|
||||
"--device-code", "device-code-secret",
|
||||
"--app-secret=top-secret",
|
||||
"--scope", "contact:read",
|
||||
})
|
||||
|
||||
want := "lark-cli auth login ..."
|
||||
if got != want {
|
||||
t.Fatalf("formatAuthCmdline() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
|
||||
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
|
||||
t.Cleanup(restore)
|
||||
|
||||
var resp *http.Response
|
||||
logHTTPResponse(resp)
|
||||
|
||||
if got := buf.String(); got != "" {
|
||||
t.Fatalf("expected no log output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
|
||||
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "status", "--verify"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
logSDKResponse(PathUserInfoV1, nil)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "path="+PathUserInfoV1) {
|
||||
t.Fatalf("expected sdk path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=0") {
|
||||
t.Fatalf("expected zero status in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "auth-error") {
|
||||
t.Fatalf("expected auth-error log entry, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "component=keychain") {
|
||||
t.Fatalf("expected component in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "op=Set") {
|
||||
t.Fatalf("expected op in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") {
|
||||
t.Fatalf("expected quoted error in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type NeedAuthorizationError struct {
|
||||
UserOpenId string
|
||||
}
|
||||
|
||||
// Error returns the error message for NeedAuthorizationError.
|
||||
func (e *NeedAuthorizationError) Error() string {
|
||||
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
|
||||
}
|
||||
@@ -44,6 +45,7 @@ type SecurityPolicyError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the error message for SecurityPolicyError.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
|
||||
@@ -51,6 +53,7 @@ func (e *SecurityPolicyError) Error() string {
|
||||
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
23
internal/auth/paths.go
Normal file
23
internal/auth/paths.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
// Common authentication paths used for logging and API calls.
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"
|
||||
// PathUserInfoV1 is the endpoint for fetching user information.
|
||||
PathUserInfoV1 = "/open-apis/authen/v1/user_info"
|
||||
// PathApplicationInfoV6Prefix is the prefix endpoint for fetching application info.
|
||||
PathApplicationInfoV6Prefix = "/open-apis/application/v6/applications/"
|
||||
)
|
||||
|
||||
// ApplicationInfoPath returns the full API path for querying an application's information.
|
||||
func ApplicationInfoPath(appId string) string {
|
||||
return PathApplicationInfoV6Prefix + appId
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMissingScopes tests the calculation of missing scopes.
|
||||
func TestMissingScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -62,6 +63,7 @@ func TestMissingScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// sliceEqual compares two string slices for equality.
|
||||
func sliceEqual(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
|
||||
@@ -25,6 +25,7 @@ type StoredUAToken struct {
|
||||
|
||||
const refreshAheadMs = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// accountKey generates a unique key for an account based on its AppID and UserOpenID.
|
||||
func accountKey(appId, userOpenId string) string {
|
||||
return fmt.Sprintf("%s:%s", appId, userOpenId)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -19,11 +21,12 @@ type SecurityPolicyTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
// base returns the underlying RoundTripper or http.DefaultTransport if nil.
|
||||
func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
@@ -82,6 +85,7 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
// MCP (JSON-RPC) response format:
|
||||
// {
|
||||
@@ -130,6 +134,7 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryHandleOAPIResponse attempts to parse a standard Lark OpenAPI formatted error response.
|
||||
func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error {
|
||||
// 1. Extract code
|
||||
code := getInt(result, "code", 0)
|
||||
@@ -180,6 +185,7 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidChallengeURL checks if the given URL is a valid challenge URL.
|
||||
func isValidChallengeURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
|
||||
@@ -19,10 +19,12 @@ import (
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// sanitizeID replaces empty IDs with "default" to prevent file path issues.
|
||||
func sanitizeID(id string) string {
|
||||
return safeIDChars.ReplaceAllString(id, "_")
|
||||
}
|
||||
@@ -98,6 +100,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
|
||||
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
|
||||
}
|
||||
|
||||
// refreshWithLock acquires a file lock before attempting to refresh the token.
|
||||
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
|
||||
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
|
||||
|
||||
@@ -126,7 +129,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
|
||||
configDir := core.GetConfigDir()
|
||||
|
||||
lockDir := filepath.Join(configDir, "locks")
|
||||
if err := os.MkdirAll(lockDir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create lock directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
|
||||
return doRefreshToken(httpClient, opts, stored)
|
||||
}
|
||||
|
||||
// doRefreshToken performs the actual HTTP request to refresh the token.
|
||||
func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
|
||||
errOut := opts.ErrOut
|
||||
if errOut == nil {
|
||||
@@ -200,6 +204,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestNewUATCallOptions validates the extraction of options from CLI config.
|
||||
func TestNewUATCallOptions(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "app123",
|
||||
|
||||
@@ -18,12 +18,13 @@ import (
|
||||
func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error {
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/authen/v1/user_info",
|
||||
ApiPath: PathUserInfoV1,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
||||
}, larkcore.WithUserAccessToken(accessToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logSDKResponse(PathUserInfoV1, apiResp)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
|
||||
@@ -4,16 +4,22 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestVerifyUserToken_TransportError verifies handling of underlying transport errors.
|
||||
func TestVerifyUserToken_TransportError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
// Register no stubs — any request will fail with "no stub" error
|
||||
@@ -28,29 +34,34 @@ func TestVerifyUserToken_TransportError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyUserToken validates normal and error response paths of the user token validation.
|
||||
func TestVerifyUserToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body interface{}
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
wantLog bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
wantErr: false,
|
||||
wantLog: true,
|
||||
},
|
||||
{
|
||||
name: "token invalid",
|
||||
body: map[string]interface{}{"code": 99991668, "msg": "invalid token"},
|
||||
wantErr: true,
|
||||
errSubstr: "[99991668]",
|
||||
wantLog: true,
|
||||
},
|
||||
{
|
||||
name: "non-JSON response",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid character",
|
||||
wantLog: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,8 +72,12 @@ func TestVerifyUserToken(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/authen/v1/user_info",
|
||||
URL: PathUserInfoV1,
|
||||
Body: tt.body,
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Tt-Logid": []string{"verify-log-id"},
|
||||
},
|
||||
})
|
||||
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
@@ -70,6 +85,14 @@ func TestVerifyUserToken(t *testing.T) {
|
||||
lark.WithHttpClient(httpmock.NewClient(reg)),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "status"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
err := VerifyUserToken(context.Background(), sdk, "test-token")
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
@@ -83,6 +106,23 @@ func TestVerifyUserToken(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
got := buf.String()
|
||||
if tt.wantLog {
|
||||
if !strings.Contains(got, "path="+PathUserInfoV1) {
|
||||
t.Fatalf("expected path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=200") {
|
||||
t.Fatalf("expected status=200 in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "x-tt-logid=verify-log-id") {
|
||||
t.Fatalf("expected x-tt-logid in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth status") {
|
||||
t.Fatalf("expected cmdline in log, got %q", got)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Fatalf("expected no log output, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -32,10 +36,26 @@ type RawApiRequest struct {
|
||||
|
||||
// APIClient wraps lark.Client for all Lark Open API calls.
|
||||
type APIClient struct {
|
||||
Config *core.CliConfig
|
||||
SDK *lark.Client // All Lark API calls go through SDK
|
||||
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
||||
ErrOut io.Writer // debug/progress output
|
||||
Config *core.CliConfig
|
||||
SDK *lark.Client // All Lark API calls go through SDK
|
||||
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
||||
ErrOut io.Writer // debug/progress output
|
||||
Credential *credential.CredentialProvider
|
||||
}
|
||||
|
||||
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
|
||||
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
|
||||
if err != nil {
|
||||
var unavailableErr *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.Token == "" {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||
@@ -74,17 +94,15 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
opts = append(opts, larkcore.WithTenantAccessToken(token))
|
||||
} else {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
|
||||
if c.Config.UserOpenId == "" {
|
||||
return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)")
|
||||
}
|
||||
token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, larkcore.WithUserAccessToken(token))
|
||||
}
|
||||
|
||||
@@ -92,6 +110,146 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
return c.SDK.Do(ctx, req, opts...)
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
||||
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
||||
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
|
||||
// any extra headers from opts are applied automatically.
|
||||
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
|
||||
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
|
||||
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
|
||||
cfg := buildConfig(opts)
|
||||
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build URL
|
||||
requestURL, err := buildStreamURL(c.Config.Brand, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build body
|
||||
bodyReader, contentType, err := buildStreamBody(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Timeout — use context deadline only; httpClient.Timeout would cut off
|
||||
// healthy streaming responses because it includes body read time.
|
||||
httpClient := *c.HTTP
|
||||
httpClient.Timeout = 0
|
||||
cancel := func() {}
|
||||
requestCtx := ctx
|
||||
if cfg.timeout > 0 {
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
}
|
||||
|
||||
// Apply headers from opts
|
||||
for k, vs := range cfg.headers {
|
||||
for _, v := range vs {
|
||||
httpReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
httpReq.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
// Handle HTTP errors internally
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := strings.TrimSpace(string(errBody))
|
||||
if msg != "" {
|
||||
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type cancelOnCloseBody struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r *cancelOnCloseBody) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
|
||||
requestURL := req.ApiPath
|
||||
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
|
||||
var pathSegs []string
|
||||
for _, segment := range strings.Split(req.ApiPath, "/") {
|
||||
if !strings.HasPrefix(segment, ":") {
|
||||
pathSegs = append(pathSegs, segment)
|
||||
continue
|
||||
}
|
||||
pathKey := strings.TrimPrefix(segment, ":")
|
||||
pathValue, ok := req.PathParams[pathKey]
|
||||
if !ok {
|
||||
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
|
||||
}
|
||||
if pathValue == "" {
|
||||
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
|
||||
}
|
||||
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
||||
}
|
||||
endpoints := core.ResolveEndpoints(brand)
|
||||
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
|
||||
}
|
||||
if query := req.QueryParams.Encode(); query != "" {
|
||||
requestURL += "?" + query
|
||||
}
|
||||
return requestURL, nil
|
||||
}
|
||||
|
||||
func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
||||
switch typed := body.(type) {
|
||||
case nil:
|
||||
return nil, "", nil
|
||||
case io.Reader:
|
||||
return typed, "", nil
|
||||
case []byte:
|
||||
return bytes.NewReader(typed), "", nil
|
||||
case string:
|
||||
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
|
||||
default:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
|
||||
}
|
||||
return bytes.NewReader(payload), "application/json", nil
|
||||
}
|
||||
}
|
||||
|
||||
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
|
||||
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
|
||||
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
|
||||
|
||||
@@ -7,13 +7,20 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// roundTripFunc is an adapter to use a function as http.RoundTripper.
|
||||
@@ -31,18 +38,36 @@ func jsonResponse(body interface{}) *http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
errBuf := &bytes.Buffer{}
|
||||
httpClient := &http.Client{Transport: rt}
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: errBuf,
|
||||
SDK: sdk,
|
||||
ErrOut: errBuf,
|
||||
Credential: testCred,
|
||||
Config: cfg,
|
||||
}, errBuf
|
||||
}
|
||||
|
||||
@@ -87,21 +112,13 @@ func TestMimeToExt(t *testing.T) {
|
||||
|
||||
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"user_id": "u123",
|
||||
"name": "Test User",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"user_id": "u123",
|
||||
"name": "Test User",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -138,21 +155,13 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
|
||||
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -186,23 +195,15 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
apiCalls++
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
apiCalls++
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -319,21 +320,13 @@ func TestBuildApiReq_QueryParams(t *testing.T) {
|
||||
|
||||
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -354,3 +347,78 @@ func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: srv.URL,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoStream() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}))
|
||||
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoSDKRequest() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "https://example.com/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoStream() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoStream() error = %v, want auth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
46
internal/client/option.go
Normal file
46
internal/client/option.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option configures API request behavior for DoStream (and future DoSDKRequest).
|
||||
type Option func(*requestConfig)
|
||||
|
||||
type requestConfig struct {
|
||||
timeout time.Duration
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
// WithTimeout sets a request-level timeout that overrides the client default.
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *requestConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders adds extra HTTP headers to the request.
|
||||
func WithHeaders(h http.Header) Option {
|
||||
return func(c *requestConfig) {
|
||||
if c.headers == nil {
|
||||
c.headers = make(http.Header)
|
||||
}
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
c.headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfig(opts []Option) requestConfig {
|
||||
var cfg requestConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ── Response routing ──
|
||||
@@ -125,7 +125,7 @@ func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interfa
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,31 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const skipAuthCheckKey = "skipAuthCheck"
|
||||
const annotationSupportedIdentities = "lark:supportedIdentities"
|
||||
|
||||
// SetSupportedIdentities marks which identities a command supports.
|
||||
func SetSupportedIdentities(cmd *cobra.Command, identities []string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[annotationSupportedIdentities] = strings.Join(identities, ",")
|
||||
}
|
||||
|
||||
// GetSupportedIdentities returns the declared identities, or nil if not declared.
|
||||
func GetSupportedIdentities(cmd *cobra.Command) []string {
|
||||
v, ok := cmd.Annotations[annotationSupportedIdentities]
|
||||
if !ok || v == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(v, ",")
|
||||
}
|
||||
|
||||
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
|
||||
func DisableAuthCheck(cmd *cobra.Command) {
|
||||
|
||||
@@ -49,3 +49,27 @@ func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
|
||||
t.Error("child should have disabled auth check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetSupportedIdentities(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if got := GetSupportedIdentities(cmd); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
SetSupportedIdentities(cmd, []string{"user", "bot"})
|
||||
got := GetSupportedIdentities(cmd)
|
||||
if len(got) != 2 || got[0] != "user" || got[1] != "bot" {
|
||||
t.Errorf("expected [user bot], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSupportedIdentities_OverwriteExisting(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test", Annotations: map[string]string{"other": "val"}}
|
||||
SetSupportedIdentities(cmd, []string{"bot"})
|
||||
if cmd.Annotations["other"] != "val" {
|
||||
t.Error("existing annotation should be preserved")
|
||||
}
|
||||
got := GetSupportedIdentities(cmd)
|
||||
if len(got) != 1 || got[0] != "bot" {
|
||||
t.Errorf("expected [bot], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,92 +4,102 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity.
|
||||
func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) {
|
||||
if as.IsBot() {
|
||||
return f.Config()
|
||||
}
|
||||
return f.AuthConfig()
|
||||
}
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
// All function fields are lazily initialized and cached after first call.
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs)
|
||||
AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
// If the user explicitly passed --as, use that value; otherwise use the configured default.
|
||||
// When the value is "auto" (or unset), auto-detect based on login state.
|
||||
func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity {
|
||||
// When the value is "auto" (or unset), auto-detect based on credential hints.
|
||||
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
|
||||
f.IdentityAutoDetected = false
|
||||
|
||||
// Strict mode: force identity regardless of flags or config.
|
||||
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
|
||||
f.ResolvedIdentity = forced
|
||||
return forced
|
||||
}
|
||||
|
||||
if cmd != nil && cmd.Flags().Changed("as") {
|
||||
if flagAs != "auto" {
|
||||
f.ResolvedIdentity = flagAs
|
||||
return flagAs
|
||||
}
|
||||
// --as auto: fall through to auto-detect
|
||||
} else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" {
|
||||
f.ResolvedIdentity = core.Identity(defaultAs)
|
||||
return f.ResolvedIdentity
|
||||
}
|
||||
// Auto-detect based on login state
|
||||
|
||||
hint := f.resolveIdentityHint(ctx)
|
||||
if cmd == nil || !cmd.Flags().Changed("as") {
|
||||
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
|
||||
f.ResolvedIdentity = defaultAs
|
||||
return f.ResolvedIdentity
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect based on credential hint
|
||||
f.IdentityAutoDetected = true
|
||||
result := f.autoDetectIdentity()
|
||||
result := autoDetectIdentityFromHint(hint)
|
||||
f.ResolvedIdentity = result
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveDefaultAs returns the configured default identity: env var > config file.
|
||||
func (f *Factory) resolveDefaultAs() string {
|
||||
if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" {
|
||||
return v
|
||||
}
|
||||
if cfg, err := f.Config(); err == nil {
|
||||
return cfg.DefaultAs
|
||||
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
if hint != nil {
|
||||
return hint.DefaultAs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise.
|
||||
func (f *Factory) autoDetectIdentity() core.Identity {
|
||||
cfg, err := f.Config()
|
||||
if err != nil || cfg.UserOpenId == "" {
|
||||
return core.AsBot
|
||||
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
if hint != nil && hint.AutoAs != "" {
|
||||
return hint.AutoAs
|
||||
}
|
||||
stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
return core.AsBot
|
||||
return core.AsBot
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
if auth.TokenStatus(stored) == "expired" {
|
||||
return core.AsBot
|
||||
hint, err := f.Credential.ResolveIdentityHint(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return core.AsUser
|
||||
return hint
|
||||
}
|
||||
|
||||
// CheckIdentity verifies the resolved identity is in the supported list.
|
||||
@@ -111,6 +121,39 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
|
||||
}
|
||||
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
acct, err := f.Credential.ResolveAccount(ctx)
|
||||
if err != nil || acct == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
switch {
|
||||
case ids.BotOnly():
|
||||
return core.StrictModeBot
|
||||
case ids.UserOnly():
|
||||
return core.StrictModeUser
|
||||
default:
|
||||
return core.StrictModeOff
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
|
||||
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
|
||||
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
|
||||
@@ -122,8 +165,7 @@ func (f *Factory) NewAPIClient() (*client.APIClient, error) {
|
||||
}
|
||||
|
||||
// NewAPIClientWithConfig creates an APIClient with an explicit config.
|
||||
// Use this when the caller has already resolved the correct user profile
|
||||
// (e.g. via AuthConfig for user-mode commands).
|
||||
// Use this when the caller has already resolved the correct config.
|
||||
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
@@ -137,5 +179,11 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
|
||||
if f.IOStreams != nil {
|
||||
errOut = f.IOStreams.ErrOut
|
||||
}
|
||||
return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil
|
||||
return &client.APIClient{
|
||||
Config: cfg,
|
||||
SDK: sdk,
|
||||
HTTP: httpClient,
|
||||
ErrOut: errOut,
|
||||
Credential: f.Credential,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -14,16 +16,26 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
func NewDefault() *Factory {
|
||||
// Initialization follows a credential-first order:
|
||||
//
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
@@ -31,28 +43,33 @@ func NewDefault() *Factory {
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
f.Config = cachedConfigFunc(f)
|
||||
f.AuthConfig = cachedAuthConfigFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
return f
|
||||
}
|
||||
|
||||
func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) {
|
||||
return sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
cfg, err := core.RequireConfig(f.Keychain)
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: f.Keychain,
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
return nil, err
|
||||
}
|
||||
cfg := acct.ToCliConfig()
|
||||
registry.InitWithBrand(cfg.Brand)
|
||||
return cfg, nil
|
||||
})
|
||||
}
|
||||
|
||||
func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) {
|
||||
return sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
return core.RequireAuth(f.Keychain)
|
||||
})
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
@@ -73,11 +90,13 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
var transport = http.DefaultTransport
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -89,25 +108,50 @@ func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
cfg, err := f.Config()
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []lark.ClientOptionFunc{
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
// Build SDK transport chain
|
||||
var sdkTransport = http.DefaultTransport
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: sdkTransport,
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
|
||||
client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...)
|
||||
return client, nil
|
||||
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.NewBaseTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
providers := extcred.Providers()
|
||||
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
|
||||
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
|
||||
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
|
||||
// happens before the command runs, so any plain-text warning written
|
||||
// to stderr would break the JSON envelope contract that AI agents
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
}
|
||||
|
||||
366
internal/cmdutil/factory_default_test.go
Normal file
366
internal/cmdutil/factory_default_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
bot := core.StrictModeBot
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
StrictMode: &bot,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
if cfg.ProfileName != "target" {
|
||||
t.Fatalf("Config() profile = %q, want %q", cfg.ProfileName, "target")
|
||||
}
|
||||
if cfg.AppID != "app-target" {
|
||||
t.Fatalf("Config() appID = %q, want %q", cfg.AppID, "app-target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
_, err := f.Config()
|
||||
if err == nil {
|
||||
t.Fatal("Config() error = nil, want non-nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("Config() error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != `profile "missing" not found` {
|
||||
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser)
|
||||
}
|
||||
if f.IdentityAutoDetected {
|
||||
t.Fatal("IdentityAutoDetected = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat-token")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
|
||||
cfg.AppID = "mutated-cli-config"
|
||||
if acct.AppID != "env-app" {
|
||||
t.Fatalf("credential account mutated via Config(): got %q, want %q", acct.AppID, "env-app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat-token")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.AppSecret != "" {
|
||||
t.Fatalf("credential account AppSecret = %q, want empty string", acct.AppSecret)
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
if cfg.AppSecret != "" {
|
||||
t.Fatalf("Config().AppSecret = %q, want empty string for token-only account", cfg.AppSecret)
|
||||
}
|
||||
if credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
t.Fatalf("Config().AppSecret = %q, want token-only no-secret marker", cfg.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (s *stubTransportProvider) Name() string { return "stub" }
|
||||
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if s.interceptor != nil {
|
||||
return s.interceptor
|
||||
}
|
||||
return &stubTransportImpl{}
|
||||
}
|
||||
|
||||
type stubTransportImpl struct{}
|
||||
|
||||
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
|
||||
// whether PostRoundTrip was called, to verify execution order.
|
||||
type headerCapturingInterceptor struct {
|
||||
preCalled bool
|
||||
postCalled bool
|
||||
}
|
||||
|
||||
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
h.preCalled = true
|
||||
// Set a custom header that should survive (no built-in override)
|
||||
req.Header.Set("X-Custom-Trace", "ext-trace-123")
|
||||
// Try to override a security header — should be overwritten by SecurityHeaderTransport
|
||||
req.Header.Set(HeaderSource, "ext-tampered")
|
||||
return func(resp *http.Response, err error) {
|
||||
h.postCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var receivedHeaders http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ic := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&stubTransportProvider{interceptor: ic})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Use HTTP transport chain (has SecurityHeaderTransport)
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// PreRoundTrip was called
|
||||
if !ic.preCalled {
|
||||
t.Fatal("PreRoundTrip was not called")
|
||||
}
|
||||
// PostRoundTrip (closure) was called
|
||||
if !ic.postCalled {
|
||||
t.Fatal("PostRoundTrip closure was not called")
|
||||
}
|
||||
// Custom header set by extension survives (no built-in override)
|
||||
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
|
||||
}
|
||||
// Security header overridden by extension is restored by SecurityHeaderTransport
|
||||
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
type ctxKeyType string
|
||||
const testKey ctxKeyType = "original"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var ctxValue any
|
||||
|
||||
// Use a custom transport that captures the context value seen by the built-in chain
|
||||
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
ctxValue = req.Context().Value(testKey)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})
|
||||
|
||||
// Interceptor that tries to tamper with context
|
||||
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
|
||||
// Try to replace context with a new one
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Built-in chain should see original context, not tampered
|
||||
if ctxValue != "original" {
|
||||
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
|
||||
}
|
||||
}
|
||||
|
||||
// interceptorFunc adapts a function to exttransport.Interceptor.
|
||||
type interceptorFunc func(*http.Request) func(*http.Response, error)
|
||||
|
||||
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
|
||||
@@ -29,7 +30,7 @@ func TestResolveAs_ExplicitAs(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("bot", true)
|
||||
|
||||
got := f.ResolveAs(cmd, core.AsBot)
|
||||
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot, got %s", got)
|
||||
}
|
||||
@@ -45,7 +46,7 @@ func TestResolveAs_ExplicitAsUser(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("user", true)
|
||||
|
||||
got := f.ResolveAs(cmd, core.AsUser)
|
||||
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
|
||||
if got != core.AsUser {
|
||||
t.Errorf("want user, got %s", got)
|
||||
}
|
||||
@@ -60,7 +61,7 @@ func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("auto", true)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (auto-detect, no login), got %s", got)
|
||||
}
|
||||
@@ -76,7 +77,7 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
|
||||
})
|
||||
cmd := newCmdWithAsFlag("auto", false) // --as not changed
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (from default-as config), got %s", got)
|
||||
}
|
||||
@@ -85,16 +86,18 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_DefaultAs_FromEnv(t *testing.T) {
|
||||
os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
|
||||
defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS")
|
||||
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Errorf("want user (from env), got %s", got)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
|
||||
}
|
||||
if !f.IdentityAutoDetected {
|
||||
t.Error("IdentityAutoDetected should be true when no account default-as is set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
|
||||
})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
// No UserOpenId → auto-detect returns bot
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (auto-detect), got %s", got)
|
||||
@@ -119,7 +122,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
|
||||
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
got := f.ResolveAs(nil, "auto")
|
||||
got := f.ResolveAs(context.Background(), nil, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot, got %s", got)
|
||||
}
|
||||
@@ -183,56 +186,6 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveConfig tests ---
|
||||
|
||||
func TestResolveConfig_Bot(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
|
||||
got, err := f.ResolveConfig(core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.AppID != "a" {
|
||||
t.Errorf("want AppID a, got %s", got.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_User(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
|
||||
got, err := f.ResolveConfig(core.AsUser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.AppID != "a" {
|
||||
t.Errorf("want AppID a, got %s", got.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- autoDetectIdentity tests ---
|
||||
|
||||
func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
got := f.autoDetectIdentity()
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (no UserOpenId), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDetectIdentity_ConfigError(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return nil, os.ErrNotExist
|
||||
},
|
||||
}
|
||||
got := f.autoDetectIdentity()
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (config error), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NewAPIClient / NewAPIClientWithConfig tests ---
|
||||
|
||||
func TestNewAPIClient(t *testing.T) {
|
||||
@@ -280,3 +233,125 @@ func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
|
||||
t.Fatal("expected non-nil APIClient")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveStrictMode tests ---
|
||||
|
||||
func TestResolveStrictMode_Off(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Errorf("expected bot, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
|
||||
t.Errorf("expected user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_BothIdentities(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off when both supported, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
f.Credential = nil
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off with nil credential, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CheckStrictMode tests ---
|
||||
|
||||
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
|
||||
t.Errorf("bot should be allowed in bot mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
err := f.CheckStrictMode(context.Background(), core.AsUser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user in bot mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
|
||||
t.Errorf("user should be allowed in user mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
err := f.CheckStrictMode(context.Background(), core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bot in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
|
||||
t.Errorf("user should be allowed when off: %v", err)
|
||||
}
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
|
||||
t.Errorf("bot should be allowed when off: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveAs strict mode tests ---
|
||||
|
||||
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("bot mode should force bot, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Errorf("user mode should force user, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("bot mode should override default-as user, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,16 @@ func ExecutionIdFromContext(ctx context.Context) (string, bool) {
|
||||
// RequestOptionFunc that injects the corresponding headers into SDK requests.
|
||||
// Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
h := ShortcutHeaders(ctx)
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return larkcore.WithHeaders(h)
|
||||
}
|
||||
|
||||
// ShortcutHeaders extracts Shortcut info from the context and returns
|
||||
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaders(ctx context.Context) http.Header {
|
||||
name, ok := ShortcutNameFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -77,5 +87,5 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
}
|
||||
return larkcore.WithHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -34,16 +36,14 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
stderrBuf := &bytes.Buffer{}
|
||||
|
||||
mockClient := httpmock.NewClient(reg)
|
||||
// SDK mock client wraps the mock transport with UserAgentTransport
|
||||
// so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x).
|
||||
sdkMockClient := &http.Client{
|
||||
Transport: &UserAgentTransport{Base: reg},
|
||||
}
|
||||
|
||||
// Build a test LarkClient using the config
|
||||
var testLarkClient *lark.Client
|
||||
if config != nil && config.AppID != "" {
|
||||
opts := []lark.ClientOptionFunc{
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(sdkMockClient),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
@@ -51,16 +51,40 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
if config.Brand != "" {
|
||||
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand)))
|
||||
}
|
||||
testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...)
|
||||
testLarkClient = lark.NewClient(config.AppID, credential.RuntimeAppSecret(config.AppSecret), opts...)
|
||||
}
|
||||
|
||||
testCred := credential.NewCredentialProvider(
|
||||
nil,
|
||||
&testDefaultAcct{config: config},
|
||||
&testDefaultToken{},
|
||||
func() (*http.Client, error) { return mockClient, nil },
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
AuthConfig: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
}
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
|
||||
type testDefaultAcct struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
if a.config == nil {
|
||||
return &credential.Account{}, nil
|
||||
}
|
||||
return credential.AccountFromCliConfig(a.config), nil
|
||||
}
|
||||
|
||||
type testDefaultToken struct{}
|
||||
|
||||
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
@@ -20,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
@@ -65,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
@@ -78,7 +82,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
@@ -98,3 +102,40 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
return t.base().RoundTrip(req)
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes and cannot be skipped or overridden.
|
||||
// The original request context is restored after PreRoundTrip to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
}
|
||||
|
||||
// RoundTrip calls PreRoundTrip, restores the original context, executes
|
||||
// the built-in chain, then calls the post hook if non-nil.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx) // isolate caller's request before extension mutations
|
||||
post := m.Ext.PreRoundTrip(req)
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// wrapWithExtension wraps transport with the registered extension middleware.
|
||||
// If no extension is registered, returns transport unchanged.
|
||||
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return transport
|
||||
}
|
||||
tr := p.ResolveInterceptor(context.Background())
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Identity represents the caller identity for API requests.
|
||||
@@ -21,6 +24,7 @@ type Identity string
|
||||
const (
|
||||
AsUser Identity = "user"
|
||||
AsBot Identity = "bot"
|
||||
AsAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IsBot returns true if the identity is bot.
|
||||
@@ -34,27 +38,129 @@ type AppUser struct {
|
||||
|
||||
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
||||
type AppConfig struct {
|
||||
AppId string `json:"appId"`
|
||||
AppSecret SecretInput `json:"appSecret"`
|
||||
Brand LarkBrand `json:"brand"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto"
|
||||
Users []AppUser `json:"users"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppId string `json:"appId"`
|
||||
AppSecret SecretInput `json:"appSecret"`
|
||||
Brand LarkBrand `json:"brand"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
||||
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
||||
Users []AppUser `json:"users"`
|
||||
}
|
||||
|
||||
// ProfileName returns the display name for this app config.
|
||||
// If Name is set, returns Name; otherwise falls back to AppId.
|
||||
func (a *AppConfig) ProfileName() string {
|
||||
if a.Name != "" {
|
||||
return a.Name
|
||||
}
|
||||
return a.AppId
|
||||
}
|
||||
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
Apps []AppConfig `json:"apps"`
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
if profileOverride != "" {
|
||||
if app := m.FindApp(profileOverride); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m.CurrentApp != "" {
|
||||
if app := m.FindApp(m.CurrentApp); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil // explicit currentApp not found; don't silently fallback
|
||||
}
|
||||
if len(m.Apps) > 0 {
|
||||
return &m.Apps[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindApp looks up an app by name, then by appId. Returns nil if not found.
|
||||
// Name match takes priority: if profile A has Name "X" and profile B has AppId "X",
|
||||
// FindApp("X") returns profile A.
|
||||
func (m *MultiAppConfig) FindApp(name string) *AppConfig {
|
||||
// First pass: match by Name
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
// Second pass: match by AppId
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found.
|
||||
func (m *MultiAppConfig) FindAppIndex(name string) int {
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ProfileNames returns all profile names (Name if set, otherwise AppId).
|
||||
func (m *MultiAppConfig) ProfileNames() []string {
|
||||
names := make([]string, len(m.Apps))
|
||||
for i := range m.Apps {
|
||||
names[i] = m.Apps[i].ProfileName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ValidateProfileName checks that a profile name is valid.
|
||||
// Rejects empty names, whitespace, control characters, and shell-problematic characters,
|
||||
// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names.
|
||||
func ValidateProfileName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name cannot be empty")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 64 {
|
||||
return fmt.Errorf("profile name %q is too long (max 64 characters)", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r <= 0x1F || r == 0x7F { // control characters
|
||||
return fmt.Errorf("invalid profile name %q: contains control characters", name)
|
||||
}
|
||||
switch r {
|
||||
case ' ', '\t', '/', '\\', '"', '\'', '`', '$', '#', '!', '&', '|', ';', '(', ')', '{', '}', '[', ']', '<', '>', '?', '*', '~':
|
||||
return fmt.Errorf("invalid profile name %q: contains invalid character %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CliConfig is the resolved single-app config used by downstream code.
|
||||
type CliConfig struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand LarkBrand
|
||||
DefaultAs string // "user" | "bot" | "auto" | "" (from config file)
|
||||
UserOpenId string
|
||||
UserName string
|
||||
ProfileName string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand LarkBrand
|
||||
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
|
||||
UserOpenId string
|
||||
UserName string
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// GetConfigDir returns the config directory path.
|
||||
@@ -64,7 +170,7 @@ func GetConfigDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
@@ -78,7 +184,7 @@ func GetConfigPath() string {
|
||||
|
||||
// LoadMultiAppConfig loads multi-app config from disk.
|
||||
func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
data, err := os.ReadFile(GetConfigPath())
|
||||
data, err := vfs.ReadFile(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,7 +202,7 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
// SaveMultiAppConfig saves config to disk.
|
||||
func SaveMultiAppConfig(config *MultiAppConfig) error {
|
||||
dir := GetConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
@@ -106,13 +212,34 @@ func SaveMultiAppConfig(config *MultiAppConfig) error {
|
||||
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
|
||||
}
|
||||
|
||||
// RequireConfig loads the single-app config. Takes Apps[0] directly.
|
||||
// RequireConfig loads the single-app config using the default profile resolution.
|
||||
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return RequireConfigForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireConfigForProfile loads the single-app config for a specific profile.
|
||||
// Resolution priority: profileOverride > config.CurrentApp > Apps[0].
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
app := raw.Apps[0]
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig.
|
||||
// This avoids re-reading the config file when the caller has already loaded it.
|
||||
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
@@ -124,10 +251,11 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
@@ -138,7 +266,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
|
||||
// RequireAuth loads config and ensures a user is logged in.
|
||||
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
cfg, err := RequireConfig(kc)
|
||||
return RequireAuthForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireAuthForProfile loads config for a profile and ensures a user is logged in.
|
||||
func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
cfg, err := RequireConfigForProfile(kc, profileOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,3 +280,11 @@ func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// formatProfileNames joins profile names for display.
|
||||
func formatProfileNames(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
58
internal/core/config_strict_mode_test.go
Normal file
58
internal/core/config_strict_mode_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode="" should be omitted (omitempty)
|
||||
m := &MultiAppConfig{
|
||||
Apps: []AppConfig{{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}},
|
||||
}
|
||||
data, _ := json.Marshal(m)
|
||||
if string(data) != `{"apps":[{"appId":"a","appSecret":"s","brand":"feishu","users":[]}]}` {
|
||||
t.Errorf("StrictMode empty should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode="bot" should be present
|
||||
m.StrictMode = StrictModeBot
|
||||
data, _ = json.Marshal(m)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "bot" {
|
||||
t.Errorf("StrictMode=bot should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode nil should be omitted
|
||||
app := &AppConfig{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}
|
||||
data, _ := json.Marshal(app)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if _, ok := parsed["strictMode"]; ok {
|
||||
t.Errorf("nil StrictMode should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "user"
|
||||
v := StrictModeUser
|
||||
app.StrictMode = &v
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "user" {
|
||||
t.Errorf("StrictMode=user should be present, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "off" (explicit off — should be present, not omitted)
|
||||
voff := StrictModeOff
|
||||
app.StrictMode = &voff
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if val, ok := parsed["strictMode"]; !ok || val != "off" {
|
||||
t.Errorf("StrictMode=off (explicit) should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,27 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
|
||||
|
||||
raw := &MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
Name: "active",
|
||||
AppId: "cli_active",
|
||||
AppSecret: PlainSecret("secret"),
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfigFromMulti() error = %v", err)
|
||||
}
|
||||
if cfg.ProfileName != "active" {
|
||||
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const secretKeyPrefix = "appsecret:"
|
||||
@@ -25,7 +25,7 @@ func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, erro
|
||||
}
|
||||
switch s.Ref.Source {
|
||||
case "file":
|
||||
data, err := os.ReadFile(s.Ref.ID)
|
||||
data, err := vfs.ReadFile(s.Ref.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err)
|
||||
}
|
||||
|
||||
42
internal/core/strict_mode.go
Normal file
42
internal/core/strict_mode.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// StrictMode represents the identity restriction policy.
|
||||
type StrictMode string
|
||||
|
||||
const (
|
||||
StrictModeOff StrictMode = "off"
|
||||
StrictModeBot StrictMode = "bot"
|
||||
StrictModeUser StrictMode = "user"
|
||||
)
|
||||
|
||||
// IsActive returns true if strict mode restricts identity.
|
||||
func (m StrictMode) IsActive() bool {
|
||||
return m == StrictModeBot || m == StrictModeUser
|
||||
}
|
||||
|
||||
// AllowsIdentity reports whether the given identity is permitted under this mode.
|
||||
func (m StrictMode) AllowsIdentity(id Identity) bool {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return id.IsBot()
|
||||
case StrictModeUser:
|
||||
return id == AsUser
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ForcedIdentity returns the identity forced by this mode, or "" if not active.
|
||||
func (m StrictMode) ForcedIdentity() Identity {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return AsBot
|
||||
case StrictModeUser:
|
||||
return AsUser
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
62
internal/core/strict_mode_test.go
Normal file
62
internal/core/strict_mode_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStrictMode_IsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
active bool
|
||||
}{
|
||||
{StrictModeOff, false},
|
||||
{"", false},
|
||||
{StrictModeBot, true},
|
||||
{StrictModeUser, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.IsActive(); got != tt.active {
|
||||
t.Errorf("StrictMode(%q).IsActive() = %v, want %v", tt.mode, got, tt.active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_AllowsIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
id Identity
|
||||
ok bool
|
||||
}{
|
||||
{StrictModeOff, AsUser, true},
|
||||
{StrictModeOff, AsBot, true},
|
||||
{StrictModeBot, AsBot, true},
|
||||
{StrictModeBot, AsUser, false},
|
||||
{StrictModeUser, AsUser, true},
|
||||
{StrictModeUser, AsBot, false},
|
||||
{"", AsUser, true},
|
||||
{"", AsBot, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.AllowsIdentity(tt.id); got != tt.ok {
|
||||
t.Errorf("StrictMode(%q).AllowsIdentity(%q) = %v, want %v", tt.mode, tt.id, got, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_ForcedIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
want Identity
|
||||
}{
|
||||
{StrictModeOff, ""},
|
||||
{StrictModeBot, AsBot},
|
||||
{StrictModeUser, AsUser},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.ForcedIdentity(); got != tt.want {
|
||||
t.Errorf("StrictMode(%q).ForcedIdentity() = %q, want %q", tt.mode, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@ const (
|
||||
BrandLark LarkBrand = "lark"
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
344
internal/credential/credential_provider.go
Normal file
344
internal/credential/credential_provider.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// DefaultTokenResolver is implemented by the default token provider.
|
||||
type DefaultTokenResolver interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
}
|
||||
|
||||
var (
|
||||
getStoredToken = auth.GetStoredToken
|
||||
getStoredTokenStatus = auth.TokenStatus
|
||||
)
|
||||
|
||||
type credentialSource interface {
|
||||
Name() string
|
||||
TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error)
|
||||
ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error)
|
||||
}
|
||||
|
||||
type extensionTokenSource struct {
|
||||
provider extcred.Provider
|
||||
}
|
||||
|
||||
func (s extensionTokenSource) Name() string { return s.provider.Name() }
|
||||
|
||||
func (s extensionTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
||||
tok, err := s.provider.ResolveToken(ctx, extcred.TokenSpec{
|
||||
Type: extcred.TokenType(req.Type.String()),
|
||||
AppID: req.AppID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if tok == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if tok.Value == "" {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
||||
}
|
||||
return &TokenResult{Token: tok.Value, Scopes: tok.Scopes}, true, nil
|
||||
}
|
||||
|
||||
func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
||||
hint := &IdentityHint{}
|
||||
if acct == nil {
|
||||
return hint, nil
|
||||
}
|
||||
hint.DefaultAs = acct.DefaultAs
|
||||
// Extension sources verify user identity via enrichUserInfo, so a resolved
|
||||
// UserOpenId is sufficient here; no keychain-backed token status lookup is needed.
|
||||
if acct.UserOpenId != "" {
|
||||
hint.AutoAs = core.AsUser
|
||||
return hint, nil
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
switch {
|
||||
case ids.UserOnly():
|
||||
hint.AutoAs = core.AsUser
|
||||
case ids.BotOnly():
|
||||
hint.AutoAs = core.AsBot
|
||||
}
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
type defaultTokenSource struct {
|
||||
resolver DefaultTokenResolver
|
||||
}
|
||||
|
||||
func (s defaultTokenSource) Name() string { return "default" }
|
||||
|
||||
func (s defaultTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
||||
if s.resolver == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
result, err := s.resolver.ResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "nil token result"}
|
||||
}
|
||||
if result.Token == "" {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
||||
hint := &IdentityHint{}
|
||||
if acct == nil {
|
||||
return hint, nil
|
||||
}
|
||||
hint.DefaultAs = acct.DefaultAs
|
||||
if acct.UserOpenId == "" {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
stored := getStoredToken(acct.AppID, acct.UserOpenId)
|
||||
if stored == nil {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
if getStoredTokenStatus(stored) == "expired" {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
hint.AutoAs = core.AsUser
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
// CredentialProvider is the unified entry point for all credential resolution.
|
||||
type CredentialProvider struct {
|
||||
providers []extcred.Provider
|
||||
defaultAcct DefaultAccountResolver
|
||||
defaultToken DefaultTokenResolver
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
hintErr error
|
||||
}
|
||||
|
||||
// NewCredentialProvider creates a CredentialProvider.
|
||||
func NewCredentialProvider(providers []extcred.Provider, defaultAcct DefaultAccountResolver, defaultToken DefaultTokenResolver, httpClient func() (*http.Client, error)) *CredentialProvider {
|
||||
return &CredentialProvider{
|
||||
providers: providers,
|
||||
defaultAcct: defaultAcct,
|
||||
defaultToken: defaultToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
p.warnOut = warnOut
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
||||
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
p.accountOnce.Do(func() {
|
||||
p.account, p.accountErr = p.doResolveAccount(ctx)
|
||||
})
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
}
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
||||
// If no UAT from extension, falls back to provider-supplied OpenID.
|
||||
func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, source credentialSource) error {
|
||||
if p.httpClient == nil || source == nil {
|
||||
return nil
|
||||
}
|
||||
tok, found, err := source.TryResolveToken(ctx, TokenSpec{Type: TokenTypeUAT, AppID: acct.AppID})
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
return nil // provider explicitly blocks UAT; skip enrichment
|
||||
}
|
||||
return fmt.Errorf("failed to resolve UAT for user identity verification: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
// Have UAT — must verify and resolve identity
|
||||
hc, err := p.httpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get HTTP client for user_info: %w", err)
|
||||
}
|
||||
info, err := fetchUserInfo(ctx, hc, acct.Brand, tok.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify user identity: %w", err)
|
||||
}
|
||||
acct.UserOpenId = info.OpenID
|
||||
acct.UserName = info.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
|
||||
func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, &TokenUnavailableError{Source: source.Name(), Type: req.Type}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveIdentityHint resolves default/auto identity guidance from the selected source.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// This matches ResolveAccount and keeps identity decisions stable within one CLI invocation.
|
||||
func (p *CredentialProvider) ResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
||||
p.hintOnce.Do(func() {
|
||||
p.hint, p.hintErr = p.doResolveIdentityHint(ctx)
|
||||
})
|
||||
return p.hint, p.hintErr
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
||||
acct, err := p.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
hint, err := source.ResolveIdentityHint(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hint == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
|
||||
for _, prov := range p.providers {
|
||||
source := extensionTokenSource{provider: prov}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
}
|
||||
|
||||
func convertAccount(ext *extcred.Account) *Account {
|
||||
return &Account{
|
||||
AppID: ext.AppID,
|
||||
AppSecret: ext.AppSecret,
|
||||
Brand: core.LarkBrand(ext.Brand),
|
||||
DefaultAs: core.Identity(ext.DefaultAs),
|
||||
ProfileName: ext.ProfileName,
|
||||
UserOpenId: ext.OpenID,
|
||||
SupportedIdentities: uint8(ext.SupportedIdentities),
|
||||
}
|
||||
}
|
||||
421
internal/credential/credential_provider_test.go
Normal file
421
internal/credential/credential_provider_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type mockExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
token *extcred.Token
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
if m.accountErr != nil {
|
||||
return nil, m.accountErr
|
||||
}
|
||||
return m.account, m.err
|
||||
}
|
||||
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
if m.tokenErr != nil {
|
||||
return nil, m.tokenErr
|
||||
}
|
||||
return m.token, m.err
|
||||
}
|
||||
|
||||
type mockDefaultAcct struct {
|
||||
account *Account
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return m.account, m.err
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountFromExtension(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "ext_app", Brand: "lark"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "ext_app" {
|
||||
t.Errorf("expected ext_app, got %s", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountFallsToDefault(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: "feishu"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "default_app" {
|
||||
t.Errorf("expected default_app, got %s", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountBlockStopsChain(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "blocker", err: &extcred.BlockError{Provider: "blocker", Reason: "denied"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
_, err := cp.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var blockErr *extcred.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "cached"}}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
a1, _ := cp.ResolveAccount(context.Background())
|
||||
a2, _ := cp.ResolveAccount(context.Background())
|
||||
if a1 != a2 {
|
||||
t.Error("expected same pointer (cached)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Errorf("expected default_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "default_tok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
var unavailableErr *TokenUnavailableError
|
||||
if !errors.As(err, &unavailableErr) {
|
||||
t.Fatalf("ResolveToken() error type = %T, want *TokenUnavailableError", err)
|
||||
}
|
||||
if unavailableErr.Source != "env" || unavailableErr.Type != TokenTypeUAT {
|
||||
t.Fatalf("ResolveToken() unavailable error = %+v, want source env and type uat", unavailableErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", err: errors.New("provider exploded")}},
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_FromExtensionAccount(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
DefaultAs: extcred.IdentityUser,
|
||||
SupportedIdentities: extcred.SupportsUser,
|
||||
}}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.DefaultAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, core.AsUser)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenState(t *testing.T) {
|
||||
origGetStoredToken := getStoredToken
|
||||
origTokenStatus := getStoredTokenStatus
|
||||
t.Cleanup(func() {
|
||||
getStoredToken = origGetStoredToken
|
||||
getStoredTokenStatus = origTokenStatus
|
||||
})
|
||||
|
||||
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
||||
if appID != "default_app" || userOpenID != "ou_default" {
|
||||
t.Fatalf("GetStoredToken() args = (%q, %q), want (%q, %q)", appID, userOpenID, "default_app", "ou_default")
|
||||
}
|
||||
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
||||
}
|
||||
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
||||
return "valid"
|
||||
}
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
origGetStoredToken := getStoredToken
|
||||
origTokenStatus := getStoredTokenStatus
|
||||
t.Cleanup(func() {
|
||||
getStoredToken = origGetStoredToken
|
||||
getStoredTokenStatus = origTokenStatus
|
||||
})
|
||||
|
||||
storedCalls := 0
|
||||
statusCalls := 0
|
||||
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
||||
storedCalls++
|
||||
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
||||
}
|
||||
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
||||
statusCalls++
|
||||
return "valid"
|
||||
}
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
if storedCalls != 1 {
|
||||
t.Fatalf("GetStoredToken() calls = %d, want 1", storedCalls)
|
||||
}
|
||||
if statusCalls != 1 {
|
||||
t.Fatalf("TokenStatus() calls = %d, want 1", statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountDoesNotEnrichWithTokenFromDifferentProvider(t *testing.T) {
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
||||
&mockDefaultAcct{account: &Account{
|
||||
AppID: "default_app",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_default",
|
||||
UserName: "Default User",
|
||||
}},
|
||||
&mockDefaultToken{},
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected enrich call")
|
||||
},
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() called %d times, want 0", httpClientCalls)
|
||||
}
|
||||
if acct.UserOpenId != "ou_default" || acct.UserName != "Default User" {
|
||||
t.Fatalf("resolved user = (%q, %q), want (%q, %q)", acct.UserOpenId, acct.UserName, "ou_default", "Default User")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountClearsUnverifiedExtensionIdentityOnTokenError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
OpenID: "ou_ext",
|
||||
}, tokenErr: errors.New("token lookup failed")}},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
t.Fatal("httpClient() should not be called when token lookup fails")
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "" || acct.UserName != "" {
|
||||
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerificationFails(t *testing.T) {
|
||||
var warnBuf bytes.Buffer
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
OpenID: "ou_ext",
|
||||
}, tokenErr: errors.New("token lookup failed")}},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
t.Fatal("httpClient() should not be called when token lookup fails")
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
cp.SetWarnOut(&warnBuf)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "" || acct.UserName != "" {
|
||||
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
||||
}
|
||||
if !strings.Contains(warnBuf.String(), "unable to verify user identity from credential source \"env\"") {
|
||||
t.Fatalf("warning output = %q, want source-specific verification warning", warnBuf.String())
|
||||
}
|
||||
if !strings.Contains(warnBuf.String(), "token lookup failed") {
|
||||
t.Fatalf("warning output = %q, want underlying error", warnBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{err: errors.New("config unavailable")},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "config unavailable" {
|
||||
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
||||
}
|
||||
}
|
||||
173
internal/credential/default_provider.go
Normal file
173
internal/credential/default_provider.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile)
|
||||
return AccountFromCliConfig(cfg), nil
|
||||
}
|
||||
|
||||
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||
app := multi.CurrentAppConfig(profileOverride)
|
||||
var mode core.StrictMode
|
||||
if app != nil && app.StrictMode != nil {
|
||||
mode = *app.StrictMode
|
||||
} else {
|
||||
mode = multi.StrictMode
|
||||
}
|
||||
switch mode {
|
||||
case core.StrictModeBot:
|
||||
return uint8(extcred.SupportsBot)
|
||||
case core.StrictModeUser:
|
||||
return uint8(extcred.SupportsUser)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls.
|
||||
// No SDK/LarkClient dependency — eliminates circular dependency with Factory.
|
||||
type DefaultTokenProvider struct {
|
||||
defaultAcct *DefaultAccountProvider
|
||||
httpClient func() (*http.Client, error)
|
||||
errOut io.Writer
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatErr error
|
||||
}
|
||||
|
||||
func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient func() (*http.Client, error), errOut io.Writer) *DefaultTokenProvider {
|
||||
return &DefaultTokenProvider{defaultAcct: defaultAcct, httpClient: httpClient, errOut: errOut}
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
switch req.Type {
|
||||
case TokenTypeUAT:
|
||||
return p.resolveUAT(ctx)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
||||
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(acct.ToCliConfig(), p.errOut))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := auth.GetStoredToken(acct.AppID, acct.UserOpenId)
|
||||
scopes := ""
|
||||
if stored != nil {
|
||||
scopes = stored.Scope
|
||||
}
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
})
|
||||
return p.tatResult, p.tatErr
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
}
|
||||
14
internal/credential/default_provider_test.go
Normal file
14
internal/credential/default_provider_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
// Just verify the type implements DefaultTokenResolver
|
||||
var _ DefaultTokenResolver = &DefaultTokenProvider{}
|
||||
}
|
||||
|
||||
func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
113
internal/credential/integration_test.go
Normal file
113
internal/credential/integration_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
type noopKC struct{}
|
||||
|
||||
func (n *noopKC) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopKC) Set(service, account, value string) error { return nil }
|
||||
func (n *noopKC) Remove(service, account string) error { return nil }
|
||||
|
||||
func TestFullChain_EnvWins(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env_app")
|
||||
t.Setenv(envvars.CliAppSecret, "env_secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "env_uat")
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "env_app" {
|
||||
t.Errorf("expected env_app, got %s", acct.AppID)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "env_app",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "env_uat" {
|
||||
t.Errorf("expected env_uat, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_Fallthrough(t *testing.T) {
|
||||
// env provider returns nil (no env vars set), falls through to default token
|
||||
ep := &envprovider.Provider{}
|
||||
mock := &mockDefaultTokenProvider{token: "mock_tok", scopes: "drive:read"}
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "mock_tok" || result.Scopes != "drive:read" {
|
||||
t.Errorf("unexpected: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type mockDefaultTokenProvider struct {
|
||||
token string
|
||||
scopes string
|
||||
}
|
||||
|
||||
func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: m.token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
botMode := core.StrictModeBot
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "cfg_app",
|
||||
AppSecret: core.PlainSecret("cfg_secret"),
|
||||
Brand: core.BrandLark,
|
||||
StrictMode: &botMode,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
defaultAcct, nil, nil,
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != uint8(extcred.SupportsBot) {
|
||||
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
172
internal/credential/types.go
Normal file
172
internal/credential/types.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Account is the credential-layer view of the active runtime account.
|
||||
// It intentionally mirrors only the resolved fields needed by runtime auth
|
||||
// and identity selection, without exposing core.CliConfig as a dependency.
|
||||
type Account struct {
|
||||
ProfileName string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand core.LarkBrand
|
||||
DefaultAs core.Identity
|
||||
UserOpenId string
|
||||
UserName string
|
||||
SupportedIdentities uint8
|
||||
}
|
||||
|
||||
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
|
||||
|
||||
// HasRealAppSecret reports whether secret is an actual app secret rather than
|
||||
// an empty/token-only marker or the internal runtime placeholder.
|
||||
func HasRealAppSecret(secret string) bool {
|
||||
return secret != "" && secret != runtimePlaceholderAppSecret
|
||||
}
|
||||
|
||||
// RuntimeAppSecret returns the SDK-compatible app secret used at runtime.
|
||||
// Token-only sources intentionally have no real secret; this helper injects a
|
||||
// private placeholder so downstream SDK validation can proceed while callers
|
||||
// still distinguish real secrets with HasRealAppSecret.
|
||||
func RuntimeAppSecret(secret string) string {
|
||||
if HasRealAppSecret(secret) {
|
||||
return secret
|
||||
}
|
||||
return runtimePlaceholderAppSecret
|
||||
}
|
||||
|
||||
func normalizeAccountAppSecret(secret string) string {
|
||||
if HasRealAppSecret(secret) {
|
||||
return secret
|
||||
}
|
||||
return extcred.NoAppSecret
|
||||
}
|
||||
|
||||
// AccountFromCliConfig copies the resolved config view into a credential.Account.
|
||||
func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return &Account{
|
||||
ProfileName: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
AppSecret: normalizeAccountAppSecret(cfg.AppSecret),
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: cfg.DefaultAs,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
UserName: cfg.UserName,
|
||||
SupportedIdentities: cfg.SupportedIdentities,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCliConfig copies the credential-layer account into the downstream config shape.
|
||||
func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &core.CliConfig{
|
||||
ProfileName: a.ProfileName,
|
||||
AppID: a.AppID,
|
||||
AppSecret: normalizeAccountAppSecret(a.AppSecret),
|
||||
Brand: a.Brand,
|
||||
DefaultAs: a.DefaultAs,
|
||||
UserOpenId: a.UserOpenId,
|
||||
UserName: a.UserName,
|
||||
SupportedIdentities: a.SupportedIdentities,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountProvider resolves app credentials.
|
||||
// Returns nil, nil to indicate "I don't handle this, try next provider".
|
||||
type AccountProvider interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// TokenType distinguishes UAT from TAT.
|
||||
// Uses string constants matching extension/credential.TokenType for zero-cost conversion.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat" // User Access Token
|
||||
TokenTypeTAT TokenType = "tat" // Tenant Access Token
|
||||
)
|
||||
|
||||
func (t TokenType) String() string { return string(t) }
|
||||
|
||||
// ParseTokenType converts a string to TokenType.
|
||||
func ParseTokenType(s string) (TokenType, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "uat":
|
||||
return TokenTypeUAT, true
|
||||
case "tat":
|
||||
return TokenTypeTAT, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// TokenSpec is the input to TokenProvider.ResolveToken.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string // identifies which app (multi-account); not sensitive
|
||||
}
|
||||
|
||||
// TokenResult is the output of TokenProvider.ResolveToken.
|
||||
type TokenResult struct {
|
||||
Token string
|
||||
Scopes string // optional, space-separated; empty = skip scope pre-check
|
||||
}
|
||||
|
||||
// IdentityHint is credential-layer guidance for resolving the effective identity.
|
||||
type IdentityHint struct {
|
||||
DefaultAs core.Identity
|
||||
AutoAs core.Identity
|
||||
}
|
||||
|
||||
// TokenUnavailableError reports that no usable token was available.
|
||||
type TokenUnavailableError struct {
|
||||
Source string
|
||||
Type TokenType
|
||||
}
|
||||
|
||||
func (e *TokenUnavailableError) Error() string {
|
||||
if e.Source != "" {
|
||||
return fmt.Sprintf("no %s available from credential source %q", e.Type, e.Source)
|
||||
}
|
||||
return fmt.Sprintf("no credential provider returned a token for %s", e.Type)
|
||||
}
|
||||
|
||||
// MalformedTokenResultError reports that a source returned an invalid token payload.
|
||||
type MalformedTokenResultError struct {
|
||||
Source string
|
||||
Type TokenType
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *MalformedTokenResultError) Error() string {
|
||||
return fmt.Sprintf("credential source %q returned malformed %s token: %s", e.Source, e.Type, e.Reason)
|
||||
}
|
||||
|
||||
// TokenProvider resolves a runtime access token.
|
||||
// Top-level resolvers should return a non-nil token or an error.
|
||||
// Chain participants may use nil, nil internally to indicate "try next source".
|
||||
type TokenProvider interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
}
|
||||
|
||||
// NewTokenSpec returns a TokenSpec with the token type automatically
|
||||
// selected based on identity: TAT for bot, UAT for user.
|
||||
func NewTokenSpec(identity core.Identity, appID string) TokenSpec {
|
||||
t := TokenTypeUAT
|
||||
if identity.IsBot() {
|
||||
t = TokenTypeTAT
|
||||
}
|
||||
return TokenSpec{Type: t, AppID: appID}
|
||||
}
|
||||
121
internal/credential/types_test.go
Normal file
121
internal/credential/types_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestTokenTypeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
tt TokenType
|
||||
want string
|
||||
}{
|
||||
{TokenTypeUAT, "uat"},
|
||||
{TokenTypeTAT, "tat"},
|
||||
{TokenType("custom"), "custom"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.tt.String(); got != tc.want {
|
||||
t.Errorf("TokenType(%q).String() = %q, want %q", tc.tt, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenType(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want TokenType
|
||||
ok bool
|
||||
}{
|
||||
{"uat", TokenTypeUAT, true},
|
||||
{"tat", TokenTypeTAT, true},
|
||||
{"UAT", TokenTypeUAT, true},
|
||||
{"bad", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := ParseTokenType(tc.s)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Errorf("ParseTokenType(%q) = (%v, %v), want (%v, %v)", tc.s, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "target",
|
||||
AppID: "app-1",
|
||||
AppSecret: "secret-1",
|
||||
Brand: core.BrandLark,
|
||||
DefaultAs: "user",
|
||||
UserOpenId: "ou_123",
|
||||
UserName: "alice",
|
||||
SupportedIdentities: 3,
|
||||
}
|
||||
|
||||
acct := AccountFromCliConfig(cfg)
|
||||
if acct == nil {
|
||||
t.Fatal("AccountFromCliConfig() = nil")
|
||||
}
|
||||
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
|
||||
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
|
||||
}
|
||||
|
||||
roundtrip := acct.ToCliConfig()
|
||||
if roundtrip == nil {
|
||||
t.Fatal("ToCliConfig() = nil")
|
||||
}
|
||||
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
|
||||
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
|
||||
}
|
||||
|
||||
roundtrip.AppID = "mutated-cli"
|
||||
acct.AppID = "mutated-account"
|
||||
|
||||
if cfg.AppID != "app-1" {
|
||||
t.Fatalf("cfg.AppID = %q, want original value", cfg.AppID)
|
||||
}
|
||||
if roundtrip.AppID != "mutated-cli" {
|
||||
t.Fatalf("roundtrip.AppID = %q, want mutated value", roundtrip.AppID)
|
||||
}
|
||||
if acct.AppID != "mutated-account" {
|
||||
t.Fatalf("acct.AppID = %q, want mutated value", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountToCliConfig_TokenOnlySecretPreservesNoAppSecret(t *testing.T) {
|
||||
acct := &Account{
|
||||
ProfileName: "env",
|
||||
AppID: "app-1",
|
||||
AppSecret: "",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
|
||||
cfg := acct.ToCliConfig()
|
||||
if cfg == nil {
|
||||
t.Fatal("ToCliConfig() = nil")
|
||||
}
|
||||
if cfg.AppSecret != "" {
|
||||
t.Fatalf("AppSecret = %q, want empty string", cfg.AppSecret)
|
||||
}
|
||||
|
||||
roundtrip := AccountFromCliConfig(cfg)
|
||||
if roundtrip == nil {
|
||||
t.Fatal("AccountFromCliConfig() = nil")
|
||||
}
|
||||
if roundtrip.AppSecret != "" {
|
||||
t.Fatalf("roundtrip.AppSecret = %q, want empty string", roundtrip.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAppSecret_TokenOnlyUsesPlaceholder(t *testing.T) {
|
||||
if got := RuntimeAppSecret(""); got == "" {
|
||||
t.Fatal("RuntimeAppSecret(\"\") = empty, want non-empty placeholder")
|
||||
}
|
||||
if HasRealAppSecret(RuntimeAppSecret("")) {
|
||||
t.Fatalf("HasRealAppSecret(RuntimeAppSecret(\"\")) = true, want false")
|
||||
}
|
||||
if got := RuntimeAppSecret("secret-1"); got != "secret-1" {
|
||||
t.Fatalf("RuntimeAppSecret(real) = %q, want %q", got, "secret-1")
|
||||
}
|
||||
}
|
||||
56
internal/credential/user_info.go
Normal file
56
internal/credential/user_info.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type userInfo struct {
|
||||
OpenID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// fetchUserInfo calls /open-apis/authen/v1/user_info with a UAT to get the user's identity.
|
||||
func fetchUserInfo(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, uat string) (*userInfo, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/authen/v1/user_info"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+uat)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("user_info API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("user_info API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return &userInfo{OpenID: result.Data.OpenID, Name: result.Data.Name}, nil
|
||||
}
|
||||
14
internal/envvars/envvars.go
Normal file
14
internal/envvars/envvars.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
const (
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
)
|
||||
161
internal/keychain/auth_log.go
Normal file
161
internal/keychain/auth_log.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var (
|
||||
authResponseLogger *log.Logger
|
||||
authResponseLoggerOnce = &sync.Once{}
|
||||
|
||||
authResponseLogNow = time.Now
|
||||
authResponseLogArgs = func() []string { return os.Args }
|
||||
)
|
||||
|
||||
func authLogDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
|
||||
return filepath.Join(home, ".lark-cli", "logs")
|
||||
}
|
||||
|
||||
func initAuthLogger() {
|
||||
authResponseLoggerOnce.Do(func() {
|
||||
if authResponseLogger != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dir := authLogDir()
|
||||
now := authResponseLogNow()
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02"))
|
||||
logPath := filepath.Join(dir, logName)
|
||||
if f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
|
||||
authResponseLogger = log.New(f, "", 0)
|
||||
cleanupOldLogs(dir, now)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FormatAuthCmdline(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(args) <= 3 {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
return strings.Join(args[:3], " ") + " ..."
|
||||
}
|
||||
|
||||
func LogAuthResponse(path string, status int, logID string) {
|
||||
initAuthLogger()
|
||||
if authResponseLogger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
authResponseLogger.Printf(
|
||||
"[lark-cli] auth-response: time=%s path=%s status=%d x-tt-logid=%s cmdline=%s",
|
||||
authResponseLogNow().Format(time.RFC3339Nano),
|
||||
path,
|
||||
status,
|
||||
logID,
|
||||
FormatAuthCmdline(authResponseLogArgs()),
|
||||
)
|
||||
}
|
||||
|
||||
func LogAuthError(component, op string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
initAuthLogger()
|
||||
if authResponseLogger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
authResponseLogger.Printf(
|
||||
"[lark-cli] auth-error: time=%s component=%s op=%s error=%q cmdline=%s",
|
||||
authResponseLogNow().Format(time.RFC3339Nano),
|
||||
component,
|
||||
op,
|
||||
err.Error(),
|
||||
FormatAuthCmdline(authResponseLogArgs()),
|
||||
)
|
||||
}
|
||||
|
||||
func SetAuthLogHooksForTest(logger *log.Logger, now func() time.Time, args func() []string) func() {
|
||||
prevLogger := authResponseLogger
|
||||
prevNow := authResponseLogNow
|
||||
prevArgs := authResponseLogArgs
|
||||
prevOnce := authResponseLoggerOnce
|
||||
|
||||
authResponseLogger = logger
|
||||
authResponseLoggerOnce = &sync.Once{}
|
||||
|
||||
if now != nil {
|
||||
authResponseLogNow = now
|
||||
}
|
||||
if args != nil {
|
||||
authResponseLogArgs = args
|
||||
}
|
||||
|
||||
return func() {
|
||||
authResponseLogger = prevLogger
|
||||
authResponseLogNow = prevNow
|
||||
authResponseLogArgs = prevArgs
|
||||
authResponseLoggerOnce = prevOnce
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupOldLogs(dir string, now time.Time) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] background log cleanup panicked: %v\n", r)
|
||||
}
|
||||
}()
|
||||
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
cutoff := now.AddDate(0, 0, -7)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "auth-") || !strings.HasSuffix(entry.Name(), ".log") {
|
||||
continue
|
||||
}
|
||||
|
||||
dateStr := strings.TrimPrefix(entry.Name(), "auth-")
|
||||
dateStr = strings.TrimSuffix(dateStr, ".log")
|
||||
|
||||
logDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location())
|
||||
if logDate.Before(cutoff) {
|
||||
_ = vfs.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var errNotInitialized = errors.New("keychain not initialized")
|
||||
var (
|
||||
// ErrNotFound is returned when the requested credential is not found.
|
||||
ErrNotFound = errors.New("keychain: item not found")
|
||||
|
||||
// errNotInitialized is an internal error indicating the master key is missing or invalid.
|
||||
errNotInitialized = errors.New("keychain not initialized")
|
||||
)
|
||||
|
||||
const (
|
||||
// LarkCliService is the unified keychain service name for all secrets
|
||||
@@ -25,9 +31,10 @@ const (
|
||||
// wrapError is a helper to wrap underlying errors into output.ExitError.
|
||||
// It formats the error message and provides a hint for troubleshooting keychain access issues.
|
||||
func wrapError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
if err == nil || errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
|
||||
|
||||
@@ -35,6 +42,11 @@ func wrapError(op string, err error) error {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
|
||||
}
|
||||
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
LogAuthError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err))
|
||||
}()
|
||||
|
||||
return output.ErrWithHint(output.ExitAPI, "config", msg, hint)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,34 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// keychainTimeout bounds system keychain access to avoid hanging on blocked prompts.
|
||||
const keychainTimeout = 5 * time.Second
|
||||
|
||||
// masterKeyBytes is the AES-256 key size used to encrypt stored secrets.
|
||||
const masterKeyBytes = 32
|
||||
|
||||
// ivBytes is the nonce size used by AES-GCM.
|
||||
const ivBytes = 12
|
||||
|
||||
// tagBytes is the authentication tag size produced by AES-GCM.
|
||||
const tagBytes = 16
|
||||
|
||||
// fileMasterKeyName is the local fallback master key file name.
|
||||
const fileMasterKeyName = "master.key.file"
|
||||
|
||||
// keyringGet is overridden in tests to simulate system keychain reads.
|
||||
var keyringGet = keyring.Get
|
||||
|
||||
// keyringSet is overridden in tests to simulate system keychain writes.
|
||||
var keyringSet = keyring.Set
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
func StorageDir(service string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".lark-cli", "keychain", service)
|
||||
}
|
||||
@@ -56,7 +73,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
go func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
encodedKey, err := keyring.Get(service, "master.key")
|
||||
encodedKey, err := keyringGet(service, "master.key")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == masterKeyBytes {
|
||||
@@ -87,7 +104,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
encodedKeyStr := base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKeyStr)
|
||||
setErr := keyringSet(service, "master.key", encodedKeyStr)
|
||||
if setErr != nil {
|
||||
resCh <- result{key: nil, err: setErr}
|
||||
return
|
||||
@@ -104,6 +121,85 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// getFileMasterKey retrieves the fallback master key from local storage.
|
||||
// If allowCreate is true, it generates and stores a new fallback master key when missing.
|
||||
func getFileMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
|
||||
key, err := vfs.ReadFile(keyPath)
|
||||
if err == nil && len(key) == masterKeyBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err == nil && len(key) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !allowCreate {
|
||||
return nil, errNotInitialized
|
||||
}
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key = make([]byte, masterKeyBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
for i := 0; i < 3; i++ {
|
||||
existingKey, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == masterKeyBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if i < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writeFailed := true
|
||||
defer func() {
|
||||
if writeFailed {
|
||||
_ = vfs.Remove(keyPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(key); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeFailed = false
|
||||
|
||||
canonicalKey, err := vfs.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
existingKey, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == masterKeyBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
if readErr == nil && len(existingKey) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(canonicalKey) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return canonicalKey, nil
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
@@ -153,13 +249,18 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
// platformGet retrieves a value from the macOS keychain.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := vfs.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key, ferr := getFileMasterKey(service, false); ferr == nil {
|
||||
if plaintext, derr := decryptData(data, key); derr == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -173,12 +274,18 @@ func platformGet(service, account string) (string, error) {
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service, true)
|
||||
key, err := getFileMasterKey(service, false)
|
||||
if err != nil {
|
||||
return err
|
||||
key, err = getMasterKey(service, true)
|
||||
if err != nil {
|
||||
key, err = getFileMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted, err := encryptData(data, key)
|
||||
@@ -188,14 +295,14 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpPath)
|
||||
defer vfs.Remove(tmpPath)
|
||||
|
||||
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
if err := vfs.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic rename to prevent file corruption during multi-process writes
|
||||
if err := os.Rename(tmpPath, targetPath); err != nil {
|
||||
if err := vfs.Rename(tmpPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -203,7 +310,7 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
// platformRemove deletes a value from the macOS keychain.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
err := vfs.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
160
internal/keychain/keychain_darwin_test.go
Normal file
160
internal/keychain/keychain_darwin_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// TestPlatformSetFallsBackToFileMasterKey verifies writes fall back to a file master key
|
||||
// when the system keychain cannot create the master key.
|
||||
func TestPlatformSetFallsBackToFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return errors.New("blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
if err := platformSet(service, account, secret); err != nil {
|
||||
t.Fatalf("platformSet() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), fileMasterKeyName)); err != nil {
|
||||
t.Fatalf("file master key not created: %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformGetPrefersFileMasterKey verifies reads prefer the file-based master key
|
||||
// before trying the system keychain master key.
|
||||
func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
fileKey := make([]byte, masterKeyBytes)
|
||||
for i := range fileKey {
|
||||
fileKey[i] = byte(i + 1)
|
||||
}
|
||||
keychainKey := make([]byte, masterKeyBytes)
|
||||
for i := range keychainKey {
|
||||
keychainKey[i] = byte(i + 33)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
encrypted, err := encryptData(secret, fileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(secret) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
|
||||
// master key path once the fallback master key already exists.
|
||||
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
t.Fatalf("keyringGet should not be called when file master key exists")
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when file master key exists")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
fileKey := make([]byte, masterKeyBytes)
|
||||
for i := range fileKey {
|
||||
fileKey[i] = byte(i + 1)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
|
||||
if err := platformSet(service, account, secret); err != nil {
|
||||
t.Fatalf("platformSet() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user