mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 11:04:26 +08:00
Compare commits
12 Commits
ci/plugin-
...
feat/optim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b0fe6751 | ||
|
|
61c6e1cc40 | ||
|
|
a5bd310d7c | ||
|
|
20c2a2d0f9 | ||
|
|
b65146ef2c | ||
|
|
8b53da5c6f | ||
|
|
520ff2263e | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 |
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -47,34 +47,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -442,7 +414,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -462,18 +434,10 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Add them back here to make them required
|
||||
# once they have proven stable.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
10
Makefile
10
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -105,14 +105,6 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -49,6 +49,9 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Short: "Device Flow authorization login",
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
With no --scope/--domain/--recommend flag, this requests scopes for all known
|
||||
business domains (equivalent to --domain all); pass --domain or --scope to narrow it.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
||||
@@ -71,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request scopes for all known domains (equivalent to --domain all)")
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
@@ -144,33 +147,6 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
selectedDomains := opts.Domains
|
||||
scopeLevel := "" // "common" or "all" (from interactive mode)
|
||||
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = sortedKnownDomains(config.Brand)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain names and suggest corrections for unknown ones
|
||||
if len(selectedDomains) > 0 {
|
||||
knownDomains := allKnownDomains(config.Brand)
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
||||
}
|
||||
available := make([]string, 0, len(knownDomains))
|
||||
for k := range knownDomains {
|
||||
available = append(available, k)
|
||||
}
|
||||
sort.Strings(available)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
@@ -178,30 +154,51 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
|
||||
if err != nil {
|
||||
return err
|
||||
// scopeOnly is the one path that must never touch the domain catalog
|
||||
// (remote or local): --scope given alone, with neither --domain nor
|
||||
// --recommend. Every other path — including bare `auth login`, now that
|
||||
// the interactive picker is gone — needs the legal domain set to resolve
|
||||
// scopes.
|
||||
scopeOnly := opts.Scope != "" && !opts.Recommend && len(selectedDomains) == 0
|
||||
|
||||
var remote map[string][]string
|
||||
var remoteOK bool
|
||||
|
||||
if !scopeOnly {
|
||||
// Pull the remote scopes.json once for this login (not cached); any
|
||||
// read failure (network/timeout/non-2xx/malformed) silently falls back
|
||||
// to the local full computation — no warning, no telemetry.
|
||||
remote, remoteOK = larkauth.FetchRemoteScopes(config.Brand)
|
||||
legalDomains, allLegalDomains := legalDomainsFor(remote, remoteOK, config.Brand)
|
||||
|
||||
// Expand --domain all against the resolved legal domain set.
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = allLegalDomains
|
||||
break
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
||||
}
|
||||
|
||||
if len(selectedDomains) > 0 {
|
||||
// Validate explicitly-supplied domain names and suggest corrections.
|
||||
for _, d := range selectedDomains {
|
||||
if !legalDomains[d] {
|
||||
if suggestion := suggestDomain(d, legalDomains); suggestion != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
||||
}
|
||||
available := make([]string, 0, len(legalDomains))
|
||||
for k := range legalDomains {
|
||||
available = append(available, k)
|
||||
}
|
||||
sort.Strings(available)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
||||
}
|
||||
}
|
||||
selectedDomains = result.Domains
|
||||
scopeLevel = result.ScopeLevel
|
||||
} else {
|
||||
log(msg.HintHeader)
|
||||
log("Common options:")
|
||||
log(msg.HintCommon1)
|
||||
log(msg.HintCommon2)
|
||||
log(msg.HintCommon3)
|
||||
log(msg.HintCommon4)
|
||||
log("")
|
||||
log("View all options:")
|
||||
log(msg.HintFooter)
|
||||
log("")
|
||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
|
||||
// Bare `auth login` and `--recommend` without `--domain` both span
|
||||
// the full legal domain set now that the interactive picker and
|
||||
// the local auto-approve filter are gone (--recommend ≡ --domain all).
|
||||
selectedDomains = allLegalDomains
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,19 +212,8 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
||||
} else {
|
||||
// --recommend without --domain: all domains
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
||||
}
|
||||
|
||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||
if opts.Recommend || scopeLevel == "common" {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes := resolveScopesForDomains(selectedDomains, remote, remoteOK, config.Brand)
|
||||
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
|
||||
@@ -382,10 +368,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -465,10 +451,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -550,6 +536,30 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveScopesForDomains resolves the scope set for the given domains. When
|
||||
// the remote scopes.json is available it takes the union of each domain's
|
||||
// user_scopes from the remote result (remote is authoritative, including
|
||||
// domains this CLI build doesn't know about locally); otherwise it falls back
|
||||
// to the local synthesis via collectScopesForDomains. Always returns a
|
||||
// deduplicated, alphabetically sorted slice.
|
||||
func resolveScopesForDomains(domains []string, remote map[string][]string, remoteOK bool, brand core.LarkBrand) []string {
|
||||
if remoteOK {
|
||||
set := make(map[string]bool)
|
||||
for _, d := range domains {
|
||||
for _, s := range remote[d] {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
return collectScopesForDomains(domains, "user", brand)
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
@@ -582,6 +592,25 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
return domains
|
||||
}
|
||||
|
||||
// legalDomainsFor returns the authoritative domain set for this login: the
|
||||
// remote scopes.json keys when available (a remote-listed domain unknown to
|
||||
// this CLI build is still legal), otherwise the local known-domain set.
|
||||
// Returns both a membership set (for --domain validation) and a sorted slice
|
||||
// (for `all` expansion and the bare-login/--recommend-without-domain default).
|
||||
func legalDomainsFor(remote map[string][]string, remoteOK bool, brand core.LarkBrand) (map[string]bool, []string) {
|
||||
if remoteOK {
|
||||
set := make(map[string]bool, len(remote))
|
||||
sorted := make([]string, 0, len(remote))
|
||||
for d := range remote {
|
||||
set[d] = true
|
||||
sorted = append(sorted, d)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
return set, sorted
|
||||
}
|
||||
return allKnownDomains(brand), sortedKnownDomains(brand)
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
|
||||
// Empty AuthTypes defaults to ["user"].
|
||||
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
)
|
||||
|
||||
// domainMeta describes a domain for the interactive selector.
|
||||
type domainMeta struct {
|
||||
Name string
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
// interactiveResult holds the user's selections from the interactive form.
|
||||
type interactiveResult struct {
|
||||
Domains []string
|
||||
ScopeLevel string // "common" or "all"
|
||||
}
|
||||
|
||||
// getDomainMetadata returns metadata for all known domains, sorted by name.
|
||||
func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut-only domains
|
||||
shortcutOnlyNames := getShortcutOnlyDomainNames()
|
||||
for _, name := range shortcutOnlyNames {
|
||||
if !seen[name] {
|
||||
dm := buildDomainMeta(name, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[sc.Service] = true
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(domains, func(i, j int) bool {
|
||||
return domains[i].Name < domains[j].Name
|
||||
})
|
||||
return domains
|
||||
}
|
||||
|
||||
// buildDomainMeta constructs a domainMeta for a given service name and language.
|
||||
// It reads from the service_descriptions.json config first, falling back to
|
||||
// from_meta spec fields if not found.
|
||||
func buildDomainMeta(name, lang string) domainMeta {
|
||||
title := registry.GetServiceTitle(name, lang)
|
||||
desc := registry.GetServiceDetailDescription(name, lang)
|
||||
if title != "" || desc != "" {
|
||||
return domainMeta{
|
||||
Name: name,
|
||||
Title: title,
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
dm := domainMeta{Name: name}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
options := make([]huh.Option[string], len(allDomains))
|
||||
for i, dm := range allDomains {
|
||||
var label string
|
||||
switch {
|
||||
case dm.Title != "" && dm.Description != "":
|
||||
label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description)
|
||||
case dm.Title != "":
|
||||
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title)
|
||||
default:
|
||||
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description)
|
||||
}
|
||||
options[i] = huh.NewOption(label, dm.Name)
|
||||
}
|
||||
|
||||
var selectedDomains []string
|
||||
var permLevel string
|
||||
|
||||
// Phase 1a: domain selection
|
||||
// Phase 1b: permission level (shown after domain selection completes)
|
||||
form1 := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title(msg.SelectDomains).
|
||||
Description(msg.DomainHint).
|
||||
Options(options...).
|
||||
Value(&selectedDomains).
|
||||
Validate(func(s []string) error {
|
||||
if len(s) == 0 {
|
||||
return fmt.Errorf(msg.ErrNoDomain)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.PermLevel).
|
||||
Options(
|
||||
huh.NewOption(msg.PermCommon, "common"),
|
||||
huh.NewOption(msg.PermAll, "all"),
|
||||
).
|
||||
Value(&permLevel),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form1.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(selectedDomains) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
||||
if permLevel == "common" {
|
||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
permLabel := msg.PermAllLabel
|
||||
if permLevel == "common" {
|
||||
permLabel = msg.PermCommonLabel
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.Summary)
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", "))
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel)
|
||||
scopePreview := strings.Join(scopes, ", ")
|
||||
if len(scopePreview) > 80 {
|
||||
scopePreview = strings.Join(scopes[:3], ", ") + ", ..."
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
|
||||
|
||||
return &interactiveResult{
|
||||
Domains: selectedDomains,
|
||||
ScopeLevel: permLevel,
|
||||
}, nil
|
||||
}
|
||||
@@ -6,21 +6,6 @@ package auth
|
||||
import "github.com/larksuite/cli/internal/i18n"
|
||||
|
||||
type loginMsg struct {
|
||||
// Interactive UI (login_interactive.go)
|
||||
SelectDomains string
|
||||
DomainHint string
|
||||
PermLevel string
|
||||
PermCommon string
|
||||
PermAll string
|
||||
Summary string
|
||||
SummaryDomains string
|
||||
SummaryPerm string
|
||||
SummaryScopes string
|
||||
PermAllLabel string
|
||||
PermCommonLabel string
|
||||
ErrNoDomain string
|
||||
ConfirmAuth string
|
||||
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
@@ -34,31 +19,9 @@ type loginMsg struct {
|
||||
NewlyGrantedScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
// Non-interactive hint (no flags)
|
||||
HintHeader string
|
||||
HintCommon1 string
|
||||
HintCommon2 string
|
||||
HintCommon3 string
|
||||
HintCommon4 string
|
||||
HintFooter string
|
||||
}
|
||||
|
||||
var loginMsgZh = &loginMsg{
|
||||
SelectDomains: "选择要授权的业务域",
|
||||
DomainHint: "空格=选择, 回车=确认",
|
||||
PermLevel: "权限类型",
|
||||
PermCommon: "常用权限",
|
||||
PermAll: "全部权限",
|
||||
Summary: "\n摘要:\n",
|
||||
SummaryDomains: " 域: %s\n",
|
||||
SummaryPerm: " 权限: %s\n",
|
||||
SummaryScopes: " Scopes (%d): %s\n\n",
|
||||
PermAllLabel: "全部权限",
|
||||
PermCommonLabel: "常用权限",
|
||||
ErrNoDomain: "请至少选择一个业务域",
|
||||
ConfirmAuth: "确认授权?",
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。",
|
||||
@@ -71,30 +34,9 @@ var loginMsgZh = &loginMsg{
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
HintHeader: "请指定要授权的权限:\n",
|
||||
HintCommon1: " --recommend 授权推荐权限",
|
||||
HintCommon2: " --domain all 授权所有已知域的权限",
|
||||
HintCommon3: " --domain calendar,task 授权日历和任务域的权限",
|
||||
HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限",
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
var loginMsgEn = &loginMsg{
|
||||
SelectDomains: "Select domains to authorize",
|
||||
DomainHint: "Space=toggle, Enter=confirm",
|
||||
PermLevel: "Permission level",
|
||||
PermCommon: "Common scopes",
|
||||
PermAll: "All scopes",
|
||||
Summary: "\nSummary:\n",
|
||||
SummaryDomains: " Domains: %s\n",
|
||||
SummaryPerm: " Level: %s\n",
|
||||
SummaryScopes: " Scopes (%d): %s\n\n",
|
||||
PermAllLabel: "All scopes",
|
||||
PermCommonLabel: "Common scopes",
|
||||
ErrNoDomain: "please select at least one domain",
|
||||
ConfirmAuth: "Confirm authorization?",
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
|
||||
@@ -107,13 +49,6 @@ var loginMsgEn = &loginMsg{
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
HintHeader: "Please specify the scopes to authorize:\n",
|
||||
HintCommon1: " --recommend authorize recommended scopes",
|
||||
HintCommon2: " --domain all authorize all known domain scopes",
|
||||
HintCommon3: " --domain calendar,task authorize calendar and task scopes",
|
||||
HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes",
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
// getLoginMsg returns the login message bundle for the given language.
|
||||
@@ -123,10 +58,3 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
}
|
||||
return loginMsgZh
|
||||
}
|
||||
|
||||
// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ func TestGetLoginMsg_Zh(t *testing.T) {
|
||||
if msg != loginMsgZh {
|
||||
t.Error("expected zh message set")
|
||||
}
|
||||
if msg.SelectDomains != "选择要授权的业务域" {
|
||||
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
|
||||
if msg.OpenURL != "在浏览器中打开以下链接进行认证:\n\n" {
|
||||
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestGetLoginMsg_En(t *testing.T) {
|
||||
if msg != loginMsgEn {
|
||||
t.Error("expected en message set")
|
||||
}
|
||||
if msg.SelectDomains != "Select domains to authorize" {
|
||||
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
|
||||
if msg.OpenURL != "Open this URL in your browser to authenticate:\n\n" {
|
||||
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,24 +77,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
t.Errorf("%s SummaryDomains has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryPerm should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryPerm, "all")
|
||||
if got == msg.SummaryPerm {
|
||||
t.Errorf("%s SummaryPerm has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryScopes should contain %d and %s
|
||||
got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c")
|
||||
if got == msg.SummaryScopes {
|
||||
t.Errorf("%s SummaryScopes has no format verb", lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,13 +140,15 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
// format together with the computed scope breakdown.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
||||
// format together with the computed scope breakdown. statusMessage is the
|
||||
// authorization response's status_message text (e.g. pending-approval),
|
||||
// passed through verbatim into the JSON payload; text mode does not render it.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary, statusMessage string) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil, statusMessage))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return
|
||||
}
|
||||
@@ -161,14 +163,17 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op
|
||||
|
||||
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
||||
// while preserving a successful login outcome when authorization completed.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
||||
// statusMessage is the authorization response's status_message text, passed
|
||||
// through into the JSON payload when authorization actually succeeded
|
||||
// (partial grant); it is unused on the failed-login path.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName, statusMessage string) error {
|
||||
if issue == nil {
|
||||
return nil
|
||||
}
|
||||
loginSucceeded := openId != ""
|
||||
if opts.JSON {
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue, statusMessage))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
@@ -198,7 +203,13 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
// authorizationCompletePayload builds the JSON payload for a completed login,
|
||||
// optionally attaching a warning when requested scopes are missing.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
||||
// statusMessage is the authorization response's status_message text (e.g.
|
||||
// "user hasn't chosen yet" / "tenant doesn't allow this" / "pending
|
||||
// approval"), passed through verbatim — the CLI does not parse, classify, or
|
||||
// truncate it. The key is always present; an empty string means
|
||||
// the upstream response carried no message, matching the stable-shape
|
||||
// convention used by the other summary fields above.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue, statusMessage string) map[string]interface{} {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
@@ -212,6 +223,7 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu
|
||||
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
||||
"missing": emptyIfNil(summary.Missing),
|
||||
"granted": emptyIfNil(summary.Granted),
|
||||
"status_message": statusMessage,
|
||||
}
|
||||
if issue != nil {
|
||||
payload["warning"] = map[string]interface{}{
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
||||
},
|
||||
"", // openId empty -> loginSucceeded = false
|
||||
"tester",
|
||||
"", // statusMessage unused on the failed-login path
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
@@ -59,3 +60,24 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizationCompletePayload_StatusMessage asserts that the
|
||||
// authorization response's status_message text is passed through into the
|
||||
// JSON payload verbatim (CLI does not parse/classify/truncate it), and
|
||||
// that the field is always present, using an empty string when there is no
|
||||
// message, consistent with the stable-output-shape convention already used
|
||||
// by "scope" and the other summary fields.
|
||||
func TestAuthorizationCompletePayload_StatusMessage(t *testing.T) {
|
||||
summary := &loginScopeSummary{Granted: []string{"a:b:c"}}
|
||||
|
||||
p := authorizationCompletePayload("ou_x", "u", summary, nil, "审批中,请等待管理员处理")
|
||||
if p["status_message"] != "审批中,请等待管理员处理" {
|
||||
t.Fatalf("status_message = %v", p["status_message"])
|
||||
}
|
||||
|
||||
// No message from upstream -> stable empty string, not an absent key.
|
||||
p2 := authorizationCompletePayload("ou_x", "u", summary, nil, "")
|
||||
if p2["status_message"] != "" {
|
||||
t.Fatalf("empty status_message should be \"\", got %v", p2["status_message"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -202,25 +202,6 @@ func TestSortedKnownDomains(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
for _, name := range getShortcutOnlyDomainNames() {
|
||||
zhDesc := registry.GetServiceDescription(name, "zh")
|
||||
enDesc := registry.GetServiceDescription(name, "en")
|
||||
if zhDesc == "" {
|
||||
t.Errorf("missing zh description for shortcut-only domain %q", name)
|
||||
}
|
||||
if enDesc == "" {
|
||||
t.Errorf("missing en description for shortcut-only domain %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
@@ -260,75 +241,82 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
nameSet := make(map[string]bool)
|
||||
for _, dm := range domains {
|
||||
nameSet[dm.Name] = true
|
||||
func TestResolveScopesForDomains_RemoteUsed(t *testing.T) {
|
||||
remote := map[string][]string{
|
||||
"im": {"im:message:send", "im:chat:read"},
|
||||
"docs": {"docs:doc:read"},
|
||||
}
|
||||
|
||||
// from_meta projects must be present
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !nameSet[p] {
|
||||
t.Errorf("from_meta project %q missing from getDomainMetadata", p)
|
||||
}
|
||||
got := resolveScopesForDomains([]string{"im"}, remote, true, core.BrandFeishu)
|
||||
want := []string{"im:chat:read", "im:message:send"} // deduped, ascending by sort.Strings
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
nameSet := make(map[string]bool)
|
||||
for _, dm := range domains {
|
||||
nameSet[dm.Name] = true
|
||||
func TestResolveScopesForDomains_UnionAcrossDomains(t *testing.T) {
|
||||
remote := map[string][]string{
|
||||
"im": {"im:message:send"},
|
||||
"docs": {"docs:doc:read"},
|
||||
}
|
||||
|
||||
for _, name := range getShortcutOnlyDomainNames() {
|
||||
if !nameSet[name] {
|
||||
t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name)
|
||||
}
|
||||
got := resolveScopesForDomains([]string{"im", "docs"}, remote, true, core.BrandFeishu)
|
||||
want := []string{"docs:doc:read", "im:message:send"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_Sorted(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for i := 1; i < len(domains); i++ {
|
||||
if domains[i].Name < domains[i-1].Name {
|
||||
t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name)
|
||||
}
|
||||
func TestResolveScopesForDomains_FallbackToLocal(t *testing.T) {
|
||||
// remoteOK=false -> falls back to local collectScopesForDomains; im must yield non-empty local scopes
|
||||
got := resolveScopesForDomains([]string{"im"}, nil, false, core.BrandFeishu)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("fallback should return non-empty local scopes for im")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Title == "" {
|
||||
t.Errorf("domain %q has empty Title", dm.Name)
|
||||
func TestLegalDomainsFor_RemoteUsed(t *testing.T) {
|
||||
// includes "newbiz", a domain unknown to this CLI build, verifying a remote-listed domain is still legal
|
||||
remote := map[string][]string{
|
||||
"im": {"im:message:send"},
|
||||
"docs": {"docs:doc:read"},
|
||||
"newbiz": {"newbiz:thing:read"},
|
||||
}
|
||||
set, sorted := legalDomainsFor(remote, true, core.BrandFeishu)
|
||||
wantSorted := []string{"docs", "im", "newbiz"} // remote keys, ascending by sort.Strings
|
||||
if !reflect.DeepEqual(sorted, wantSorted) {
|
||||
t.Fatalf("sorted = %v, want %v", sorted, wantSorted)
|
||||
}
|
||||
if len(set) != len(wantSorted) {
|
||||
t.Fatalf("set size = %d, want %d", len(set), len(wantSorted))
|
||||
}
|
||||
for _, d := range wantSorted {
|
||||
if !set[d] {
|
||||
t.Errorf("set missing domain %q", d)
|
||||
}
|
||||
}
|
||||
if !set["newbiz"] {
|
||||
t.Error("remote-listed domain unknown to this build should still be legal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
// TestFactory has IsTerminal=false by default
|
||||
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
|
||||
err := authLoginRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-terminal without flags")
|
||||
func TestLegalDomainsFor_FallbackToLocal(t *testing.T) {
|
||||
// remoteOK=false -> falls back to local allKnownDomains/sortedKnownDomains
|
||||
set, sorted := legalDomainsFor(nil, false, core.BrandFeishu)
|
||||
if len(sorted) == 0 {
|
||||
t.Fatal("fallback should return non-empty local domain slice")
|
||||
}
|
||||
// Should mention specifying scopes
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "scopes") {
|
||||
t.Errorf("expected error to mention scopes, got: %s", msg)
|
||||
// set and sorted are two views of the same local domain set and must correspond
|
||||
if len(set) != len(sorted) {
|
||||
t.Fatalf("set size %d != sorted size %d", len(set), len(sorted))
|
||||
}
|
||||
// Stderr should explain the split-flow path for non-streaming agents.
|
||||
stderrStr := stderr.String()
|
||||
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
|
||||
if !strings.Contains(stderrStr, want) {
|
||||
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
|
||||
for _, d := range sorted {
|
||||
if !set[d] {
|
||||
t.Errorf("set missing local domain %q", d)
|
||||
}
|
||||
}
|
||||
// fallback adopts the local sort order directly, which must equal sortedKnownDomains
|
||||
if want := sortedKnownDomains(core.BrandFeishu); !reflect.DeepEqual(sorted, want) {
|
||||
t.Fatalf("sorted = %v, want sortedKnownDomains %v", sorted, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequestedScopesGranted(t *testing.T) {
|
||||
@@ -376,7 +364,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
})
|
||||
}, "")
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
@@ -406,7 +394,7 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
}, "ou_user", "tester", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -448,7 +436,7 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
}, "ou_user", "tester", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -480,7 +468,7 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Granted: []string{"offline_access"},
|
||||
})
|
||||
}, "")
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
@@ -565,7 +553,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary, "")
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range tt.expectedPresent {
|
||||
@@ -819,7 +807,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
Requested: []string{"im:message:send"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send"},
|
||||
})
|
||||
}, "")
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -1167,15 +1155,6 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "event" {
|
||||
t.Error("event should not appear in interactive domain list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains("")
|
||||
if domains["whiteboard"] {
|
||||
@@ -1200,12 +1179,3 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type DeviceFlowTokenData struct {
|
||||
ExpiresIn int
|
||||
RefreshExpiresIn int
|
||||
Scope string
|
||||
StatusMessage string // authorization result text from the response's status_message (e.g. pending-approval); passed through verbatim, not parsed
|
||||
}
|
||||
|
||||
// DeviceFlowResult is the result of polling the token endpoint.
|
||||
@@ -222,6 +223,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
ExpiresIn: tokenExpiresIn,
|
||||
RefreshExpiresIn: refreshExpiresIn,
|
||||
Scope: getStr(data, "scope"),
|
||||
StatusMessage: getStr(data, "status_message"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,3 +216,34 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollDeviceToken_SuccessIncludesStatusMessage asserts that the success
|
||||
// branch reads the token response's status_message field verbatim into
|
||||
// DeviceFlowTokenData.StatusMessage. The CLI is a pure passthrough here — no
|
||||
// parsing/classification of the text.
|
||||
func TestPollDeviceToken_SuccessIncludesStatusMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "test-token",
|
||||
"refresh_token": "test-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "a:b:c",
|
||||
"status_message": "审批中,请等待管理员处理",
|
||||
},
|
||||
})
|
||||
|
||||
result := PollDeviceToken(context.Background(), httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 10, nil)
|
||||
if result == nil || !result.OK || result.Token == nil {
|
||||
t.Fatalf("PollDeviceToken() = %+v, want OK with a token", result)
|
||||
}
|
||||
if result.Token.StatusMessage != "审批中,请等待管理员处理" {
|
||||
t.Fatalf("StatusMessage = %q, want the approval-pending text", result.Token.StatusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
107
internal/auth/remote_scopes.go
Normal file
107
internal/auth/remote_scopes.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
remoteScopesPath = "/lark-cli/apis/scopes.json"
|
||||
remoteScopesTimeout = 1 * time.Second
|
||||
maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go
|
||||
)
|
||||
|
||||
// remoteScopesURLForTest is the injection seam for unit tests to point at an
|
||||
// httptest server. It is empty at runtime, where the brand-hardcoded production
|
||||
// URL is used, and must never carry an internal / non-production domain.
|
||||
var remoteScopesURLForTest string
|
||||
|
||||
type remoteScopesFile struct {
|
||||
Scopes map[string]remoteDomainScopes `json:"scopes"`
|
||||
}
|
||||
|
||||
type remoteDomainScopes struct {
|
||||
UserScopes []string `json:"user_scopes"`
|
||||
TenantScopes []string `json:"tenant_scopes"`
|
||||
}
|
||||
|
||||
func remoteScopesURL(brand core.LarkBrand) string {
|
||||
if remoteScopesURLForTest != "" {
|
||||
return remoteScopesURLForTest
|
||||
}
|
||||
return core.ResolveOpenBaseURL(brand) + remoteScopesPath
|
||||
}
|
||||
|
||||
// FetchRemoteScopes fetches and binary-validates the remote scopes.json.
|
||||
// It returns (domain -> user_scopes, true) when the whole file is usable, or
|
||||
// (nil, false) when the caller should fall back to the local set. Any failure
|
||||
// (network / timeout / non-2xx / empty / bad JSON / structure mismatch /
|
||||
// malformed scope) returns (nil, false) silently — no warning, no telemetry.
|
||||
func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) {
|
||||
client := transport.NewHTTPClient(remoteScopesTimeout)
|
||||
req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, false
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize))
|
||||
if err != nil || len(body) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var file remoteScopesFile
|
||||
if err := json.Unmarshal(body, &file); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return validateRemoteScopes(file)
|
||||
}
|
||||
|
||||
func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) {
|
||||
if len(file.Scopes) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
result := make(map[string][]string, len(file.Scopes))
|
||||
for domain, ds := range file.Scopes {
|
||||
if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted
|
||||
return nil, false
|
||||
}
|
||||
for _, s := range ds.UserScopes {
|
||||
if !isValidScopeFormat(s) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
result[domain] = ds.UserScopes
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
// isValidScopeFormat checks the service:resource:action shape: exactly three
|
||||
// ":"-separated segments, each non-empty. The resource segment may contain "."
|
||||
// (e.g. vc:meeting.meetingevent:read). i18n / tenant / version are not checked.
|
||||
func isValidScopeFormat(s string) bool {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
100
internal/auth/remote_scopes_test.go
Normal file
100
internal/auth/remote_scopes_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func withRemoteScopesURL(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
prev := remoteScopesURLForTest
|
||||
remoteScopesURLForTest = url
|
||||
t.Cleanup(func() { remoteScopesURLForTest = prev })
|
||||
}
|
||||
|
||||
func TestFetchRemoteScopes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantOK bool
|
||||
wantDom string
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "whole usable returns all user_scopes incl unknown domain",
|
||||
status: 200,
|
||||
body: `{"version":"1.5.3","scopes":{"bitable":{"i18n_name":{"zh_cn":"多维表格"},"user_scopes":["base:app:copy","base:app:create"],"tenant_scopes":["base:app:copy"]},"brandnewdomain":{"user_scopes":["newsvc:res:read"]}}}`,
|
||||
wantOK: true,
|
||||
wantDom: "brandnewdomain",
|
||||
wantLen: 1,
|
||||
},
|
||||
{name: "missing scopes key falls back", status: 200, body: `{"version":"1"}`, wantOK: false},
|
||||
{name: "empty scopes falls back", status: 200, body: `{"scopes":{}}`, wantOK: false},
|
||||
{name: "domain missing user_scopes falls back", status: 200, body: `{"scopes":{"im":{"i18n_name":{"zh_cn":"消息"}}}}`, wantOK: false},
|
||||
{name: "malformed scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message"]}}}`, wantOK: false},
|
||||
{name: "non-2xx falls back", status: 500, body: `{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`, wantOK: false},
|
||||
{name: "empty body falls back", status: 200, body: ``, wantOK: false},
|
||||
{name: "bad json falls back", status: 200, body: `{not json`, wantOK: false},
|
||||
{name: "i18n/tenant missing still usable", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message:send_as_bot","vc:meeting.meetingevent:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 2},
|
||||
{name: "empty user_scopes array is usable", status: 200, body: `{"scopes":{"im":{"user_scopes":[]}}}`, wantOK: true, wantDom: "im", wantLen: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
withRemoteScopesURL(t, srv.URL)
|
||||
|
||||
got, ok := FetchRemoteScopes(core.BrandFeishu)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if tc.wantOK {
|
||||
if _, exists := got[tc.wantDom]; !exists {
|
||||
t.Fatalf("domain %q missing in result %v", tc.wantDom, got)
|
||||
}
|
||||
if len(got[tc.wantDom]) != tc.wantLen {
|
||||
t.Fatalf("len(%s) = %d, want %d", tc.wantDom, len(got[tc.wantDom]), tc.wantLen)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteScopesTimeoutFallsBack(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(remoteScopesTimeout + 500*time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
withRemoteScopesURL(t, srv.URL)
|
||||
|
||||
_, ok := FetchRemoteScopes(core.BrandFeishu)
|
||||
if ok {
|
||||
t.Fatal("expected fallback (ok=false) on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteScopesURLByBrand(t *testing.T) {
|
||||
// With an empty seam the production URL is used: core.ResolveOpenBaseURL(brand) + path
|
||||
remoteScopesURLForTest = ""
|
||||
feishu := remoteScopesURL(core.BrandFeishu)
|
||||
lark := remoteScopesURL(core.BrandLark)
|
||||
if !strings.HasSuffix(feishu, "/lark-cli/apis/scopes.json") || !strings.Contains(feishu, "open.feishu.cn") {
|
||||
t.Fatalf("feishu url unexpected: %s", feishu)
|
||||
}
|
||||
if !strings.Contains(lark, "open.larksuite.com") {
|
||||
t.Fatalf("lark url unexpected: %s", lark)
|
||||
}
|
||||
}
|
||||
@@ -165,9 +165,6 @@ const DefaultScopeScore = 0
|
||||
|
||||
var cachedScopePriorities map[string]int
|
||||
var cachedAutoApproveSet map[string]bool
|
||||
var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only
|
||||
var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only
|
||||
var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only
|
||||
|
||||
// scopePriorityEntry is used to parse scope_priorities.json entries.
|
||||
type scopePriorityEntry struct {
|
||||
@@ -264,90 +261,6 @@ func LoadAutoApproveSet() map[string]bool {
|
||||
return cachedAutoApproveSet
|
||||
}
|
||||
|
||||
// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform
|
||||
// (from scope_priorities.json only, before overrides).
|
||||
func LoadPlatformAutoApproveSet() map[string]bool {
|
||||
if cachedPlatformAutoApprove != nil {
|
||||
return cachedPlatformAutoApprove
|
||||
}
|
||||
m := make(map[string]bool)
|
||||
if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil {
|
||||
var entries []scopePriorityEntry
|
||||
if json.Unmarshal(data, &entries) == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.Recommend == "true" {
|
||||
m[entry.ScopeName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedPlatformAutoApprove = m
|
||||
return cachedPlatformAutoApprove
|
||||
}
|
||||
|
||||
// LoadOverrideAutoApproveAllow returns scopes explicitly listed in
|
||||
// scope_overrides.json recommend.allow (our desired additions).
|
||||
func LoadOverrideAutoApproveAllow() map[string]bool {
|
||||
if cachedOverrideAutoAllow != nil {
|
||||
return cachedOverrideAutoAllow
|
||||
}
|
||||
m := make(map[string]bool)
|
||||
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
|
||||
var wrapper struct {
|
||||
AutoApprove struct {
|
||||
Allow []string `json:"allow"`
|
||||
} `json:"recommend"`
|
||||
}
|
||||
if json.Unmarshal(data, &wrapper) == nil {
|
||||
for _, s := range wrapper.AutoApprove.Allow {
|
||||
m[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedOverrideAutoAllow = m
|
||||
return cachedOverrideAutoAllow
|
||||
}
|
||||
|
||||
// LoadOverrideAutoApproveDeny returns scopes explicitly listed in
|
||||
// scope_overrides.json recommend.deny
|
||||
func LoadOverrideAutoApproveDeny() map[string]bool {
|
||||
if cachedOverrideAutoDeny != nil {
|
||||
return cachedOverrideAutoDeny
|
||||
}
|
||||
m := make(map[string]bool)
|
||||
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
|
||||
var wrapper struct {
|
||||
AutoApprove struct {
|
||||
Deny []string `json:"deny"`
|
||||
} `json:"recommend"`
|
||||
}
|
||||
if json.Unmarshal(data, &wrapper) == nil {
|
||||
for _, s := range wrapper.AutoApprove.Deny {
|
||||
m[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedOverrideAutoDeny = m
|
||||
return cachedOverrideAutoDeny
|
||||
}
|
||||
|
||||
// IsAutoApproveScope returns true if the scope has AutoApprove rule.
|
||||
func IsAutoApproveScope(scope string) bool {
|
||||
return LoadAutoApproveSet()[scope]
|
||||
}
|
||||
|
||||
// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes.
|
||||
func FilterAutoApproveScopes(scopes []string) []string {
|
||||
autoApprove := LoadAutoApproveSet()
|
||||
var result []string
|
||||
for _, s := range scopes {
|
||||
if autoApprove[s] {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found.
|
||||
func GetScopeScore(scope string) int {
|
||||
priorities := LoadScopePriorities()
|
||||
|
||||
@@ -235,83 +235,6 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||
}
|
||||
|
||||
func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
paaSet := LoadPlatformAutoApproveSet()
|
||||
// This should only include scopes from scope_priorities.json with AutoApprove rule.
|
||||
// It does NOT apply deny overrides.
|
||||
if len(paaSet) == 0 {
|
||||
t.Fatal("expected non-empty platform auto-approve set")
|
||||
}
|
||||
|
||||
t.Logf("Platform auto-approve set has %d scopes", len(paaSet))
|
||||
}
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
||||
denySet := LoadOverrideAutoApproveDeny()
|
||||
// deny list may be empty if all entries are moved to _deny (commented out)
|
||||
t.Logf("Override deny set has %d scopes", len(denySet))
|
||||
}
|
||||
|
||||
func TestIsAutoApproveScope(t *testing.T) {
|
||||
// Known auto-approve scope (recommend=true in scope_priorities.json)
|
||||
if !IsAutoApproveScope("sheets:spreadsheet:read") {
|
||||
t.Error("expected sheets:spreadsheet:read to be auto-approve")
|
||||
}
|
||||
|
||||
// Completely unknown scope
|
||||
if IsAutoApproveScope("zzz:unknown:scope") {
|
||||
t.Error("expected unknown scope to NOT be auto-approve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
}
|
||||
|
||||
result := FilterAutoApproveScopes(scopes)
|
||||
if len(result) < 1 {
|
||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||
}
|
||||
|
||||
// Check that sheets:spreadsheet:read is included
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if s == "sheets:spreadsheet:read" {
|
||||
found = true
|
||||
}
|
||||
// Ensure unknown scopes are not included
|
||||
if s == "zzz:unknown:scope" {
|
||||
t.Error("unknown scope should not be in auto-approve result")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected sheets:spreadsheet:read in result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAutoApproveScopes_Empty(t *testing.T) {
|
||||
result := FilterAutoApproveScopes(nil)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
|
||||
result = FilterAutoApproveScopes([]string{})
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for empty input, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func TestGetRegistryDir(t *testing.T) {
|
||||
|
||||
@@ -40,9 +40,6 @@ func resetInit() {
|
||||
cachedAllScopes = nil
|
||||
cachedScopePriorities = nil
|
||||
cachedAutoApproveSet = nil
|
||||
cachedPlatformAutoApprove = nil
|
||||
cachedOverrideAutoAllow = nil
|
||||
cachedOverrideAutoDeny = nil
|
||||
refreshOnce = sync.Once{}
|
||||
configuredBrand = ""
|
||||
enableRemoteMeta = true // tests exercise remote logic
|
||||
|
||||
@@ -58,26 +58,6 @@ func GetServiceDescription(name, lang string) string {
|
||||
return loc.Description
|
||||
}
|
||||
|
||||
// GetServiceTitle returns the localized title for a service domain.
|
||||
// Returns empty string if not found.
|
||||
func GetServiceTitle(name, lang string) string {
|
||||
loc := getServiceLocale(name, lang)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
return loc.Title
|
||||
}
|
||||
|
||||
// GetServiceDetailDescription returns the localized detail description for a service domain.
|
||||
// Returns empty string if not found.
|
||||
func GetServiceDetailDescription(name, lang string) string {
|
||||
loc := getServiceLocale(name, lang)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
return loc.Description
|
||||
}
|
||||
|
||||
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
|
||||
// When auth_domain is set, the service's scopes are collected under the
|
||||
// parent domain during auth login.
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
statusParams := map[string]interface{}{}
|
||||
if env != "" {
|
||||
statusParams["env"] = env
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||
"table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
exportQuery := larkcore.QueryParams{
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
exportQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: exportQuery,
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
importQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
QueryParams: importQuery,
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
}
|
||||
if p["table"] != "orders" {
|
||||
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||
// 交由下面真实 apply 统一报同样的业务错。
|
||||
pending := 0
|
||||
var previewFrom, previewTo string
|
||||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||
pending = len(projectMigrationChanges(preview["changes"]))
|
||||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
if from == "" {
|
||||
from = previewFrom
|
||||
}
|
||||
if to == "" {
|
||||
to = previewTo
|
||||
}
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||
if applied == 0 && pending > 0 {
|
||||
applied = pending
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
|
||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
"transactional": false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
raw = append(raw, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||
// 两行 + tables_affected 翻倍。
|
||||
hasSchema := map[string]bool{}
|
||||
for _, c := range raw {
|
||||
if c.Action != "" {
|
||||
hasSchema[c.Table] = true
|
||||
}
|
||||
}
|
||||
changes := make([]recoveryChange, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
if c.Action == "" && hasSchema[c.Table] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, c)
|
||||
}
|
||||
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range changes {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
{"non-numeric string", "x", 0, false},
|
||||
{"numeric string", "13.5", 13.5, true},
|
||||
{"numeric string int", "2", 2, true},
|
||||
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||
{"empty string", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
||||
return rctx.Str("environment")
|
||||
}
|
||||
|
||||
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||
if rctx.Changed("env") {
|
||||
|
||||
@@ -27,12 +27,17 @@ const (
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
|
||||
whiteboardTag = "whiteboard"
|
||||
whiteboardTypeAttr = "type"
|
||||
whiteboardPathAttr = "path"
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
type whiteboardStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
|
||||
if !strings.Contains(content, "<whiteboard") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteWhiteboardFileRefs(runtime, segment)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return rewrite(content)
|
||||
}
|
||||
|
||||
var rewriteErrs []error
|
||||
out := applyOutsideCodeFences(content, func(segment string) string {
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
rewriteErrs = append(rewriteErrs, rewriteErr)
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
|
||||
var rewriteErrs []error
|
||||
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
|
||||
if err != nil {
|
||||
rewriteErrs = append(rewriteErrs, err)
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
|
||||
startRaw, body, _, ok := splitWhiteboardElement(raw)
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
tag, err := parseWhiteboardStartTag(startRaw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(whiteboardPathAttr)
|
||||
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
|
||||
if !hasPath && !hasBodyPath {
|
||||
return raw, nil
|
||||
}
|
||||
if hasPath && strings.TrimSpace(body) != "" {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
|
||||
}
|
||||
if hasPath && hasBodyPath {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
|
||||
}
|
||||
|
||||
typRaw, ok := tag.attr(whiteboardTypeAttr)
|
||||
if !ok || strings.TrimSpace(typRaw) == "" {
|
||||
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
|
||||
}
|
||||
typ, ok := canonicalWhiteboardFileType(typRaw)
|
||||
if !ok {
|
||||
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
|
||||
}
|
||||
|
||||
if hasBodyPath {
|
||||
pathValue = bodyPath
|
||||
}
|
||||
data, err := readWhiteboardPath(runtime, pathValue, typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag.setAttr(whiteboardTypeAttr, typ)
|
||||
tag.removeAttrs(whiteboardPathAttr)
|
||||
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
|
||||
}
|
||||
|
||||
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing = strings.HasSuffix(trimmed, "/>")
|
||||
if selfClosing {
|
||||
return raw, "", true, true
|
||||
}
|
||||
startEnd := strings.Index(raw, ">")
|
||||
if startEnd < 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
|
||||
if endStart < 0 || endStart < startEnd {
|
||||
return "", "", false, false
|
||||
}
|
||||
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
|
||||
}
|
||||
|
||||
func whiteboardBodyPathRef(body string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func canonicalWhiteboardFileType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "svg":
|
||||
return "svg", true
|
||||
case "mermaid":
|
||||
return "mermaid", true
|
||||
case "plantuml":
|
||||
return "plantuml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
|
||||
}
|
||||
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
|
||||
WithParam("path").
|
||||
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
|
||||
WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func whiteboardExtAllowed(typ string, ext string) bool {
|
||||
for _, allowed := range whiteboardAllowedExts(typ) {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func whiteboardAllowedExts(typ string) []string {
|
||||
switch typ {
|
||||
case "svg":
|
||||
return []string{".svg"}
|
||||
case "mermaid":
|
||||
return []string{".mermaid", ".mmd"}
|
||||
case "plantuml":
|
||||
return []string{".plantuml", ".puml", ".pu", ".uml"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func whiteboardExtList(typ string) string {
|
||||
return strings.Join(whiteboardAllowedExts(typ), ", ")
|
||||
}
|
||||
|
||||
func exampleWhiteboardExt(typ string) string {
|
||||
exts := whiteboardAllowedExts(typ)
|
||||
if len(exts) == 0 {
|
||||
return "txt"
|
||||
}
|
||||
return strings.TrimPrefix(exts[0], ".")
|
||||
}
|
||||
|
||||
func whiteboardContentForType(typ string, data string) string {
|
||||
if typ == "svg" {
|
||||
return data
|
||||
}
|
||||
return escapeXMLText(data)
|
||||
}
|
||||
|
||||
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
|
||||
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
|
||||
messages := make([]string, 0, len(flatErrs))
|
||||
params := make([]errs.InvalidParam, 0, len(flatErrs))
|
||||
for _, err := range flatErrs {
|
||||
messages = append(messages, err.Error())
|
||||
params = append(params, whiteboardInvalidParamsFromError(err)...)
|
||||
}
|
||||
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
|
||||
WithParam("whiteboard").
|
||||
WithCause(errors.Join(flatErrs...))
|
||||
if len(params) > 0 {
|
||||
validationErr.WithParams(params...)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
|
||||
flatErrs := make([]error, 0, len(rewriteErrs))
|
||||
for _, err := range rewriteErrs {
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
|
||||
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
|
||||
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
flatErrs = append(flatErrs, err)
|
||||
}
|
||||
return flatErrs
|
||||
}
|
||||
|
||||
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return nil
|
||||
}
|
||||
if len(validationErr.Params) > 0 {
|
||||
return validationErr.Params
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return whiteboardStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != whiteboardTag {
|
||||
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) setAttr(name string, value string) {
|
||||
for i, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
t.Attrs[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(whiteboardTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ package doc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
files := map[string]string{
|
||||
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||
"flow.mmd": "flowchart TD\nA --> B",
|
||||
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
got := body["content"].(string)
|
||||
for _, want := range []string{
|
||||
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `path="@`) {
|
||||
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||
}
|
||||
if _, ok := body["reference_map"]; ok {
|
||||
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"missing.svg",
|
||||
"missing.mmd",
|
||||
"missing.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "missing.svg" cannot be read`,
|
||||
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"before.svg",
|
||||
"after.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "before.svg" cannot be read`,
|
||||
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||
})
|
||||
if strings.Contains(err.Error(), "inside.svg") {
|
||||
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "whiteboard" {
|
||||
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||
}
|
||||
if validationErr.Cause == nil {
|
||||
t.Fatal("expected aggregated error to preserve cause")
|
||||
}
|
||||
var childValidationErr *errs.ValidationError
|
||||
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||
}
|
||||
|
||||
gotParams := make(map[string]string, len(validationErr.Params))
|
||||
for _, param := range validationErr.Params {
|
||||
gotParams[param.Name] = param.Reason
|
||||
}
|
||||
if len(gotParams) != len(wantParams) {
|
||||
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for _, param := range wantParams {
|
||||
reason, ok := gotParams[param]
|
||||
if !ok {
|
||||
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||
}
|
||||
}
|
||||
for _, want := range wantMessages {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q:\n%v", want, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -25,6 +27,9 @@ const (
|
||||
minVCMeetingEventsPageSize = 20
|
||||
maxVCMeetingEventsPageSize = 100
|
||||
maxVCMeetingEventsPages = 200
|
||||
leaveReasonUserLeft = 1
|
||||
leaveReasonMeetingEnded = 2
|
||||
leaveReasonKicked = 3
|
||||
)
|
||||
|
||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
||||
// VCMeetingEvents lists meeting events for a meeting.
|
||||
var VCMeetingEvents = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-events",
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Description: "List meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
outData := map[string]interface{}{
|
||||
"events": events,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
|
||||
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
|
||||
metadata := map[string]interface{}{
|
||||
"row_type": "metadata",
|
||||
"meeting": outData.Meeting,
|
||||
"identity": outData.Identity,
|
||||
"has_more": outData.HasMore,
|
||||
"page_token": outData.PageToken,
|
||||
}
|
||||
if len(outData.Warnings) > 0 {
|
||||
metadata["warnings"] = outData.Warnings
|
||||
}
|
||||
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
|
||||
|
||||
timeline := buildMeetingEventTimeline(events)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
})
|
||||
if runtime.Format == "ndjson" {
|
||||
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
|
||||
} else {
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
renderMeetingEventsCompactPretty(w, outData, timeline)
|
||||
})
|
||||
}
|
||||
if runtime.Format == "pretty" && pageToken != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||
if hasMore {
|
||||
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
type meetingEventsOutput struct {
|
||||
Meeting meetingEventsMeeting `json:"meeting"`
|
||||
Identity meetingEventsIdentity `json:"identity"`
|
||||
Events []meetingEventsEvent `json:"events"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsMeeting struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
MeetingNo string `json:"meeting_no,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type meetingEventsIdentity struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
Actors []meetingEventsIdentity `json:"actors,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEndSignal struct {
|
||||
Ended bool
|
||||
EndTime time.Time
|
||||
HasEndTime bool
|
||||
}
|
||||
|
||||
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
|
||||
output := meetingEventsOutput{
|
||||
Meeting: meetingEventsMeetingFromPayload(nil),
|
||||
Identity: identity,
|
||||
HasMore: common.GetBool(data, "has_more"),
|
||||
PageToken: common.GetString(data, "page_token"),
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
if warning = strings.TrimSpace(warning); warning != "" {
|
||||
output.Warnings = append(output.Warnings, warning)
|
||||
}
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
|
||||
output.Meeting = meetingEventsMeetingFromPayload(meeting)
|
||||
}
|
||||
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
|
||||
}
|
||||
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
|
||||
return output
|
||||
}
|
||||
|
||||
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
|
||||
if runtime.As() == core.AsBot {
|
||||
botInfo, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
|
||||
}
|
||||
return meetingEventsBotIdentity(botInfo), ""
|
||||
}
|
||||
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||
identity := meetingEventsIdentity{
|
||||
ID: userOpenID,
|
||||
Name: strings.TrimSpace(runtime.Config.UserName),
|
||||
ParticipantType: "human",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
if userOpenID == "" {
|
||||
return identity, "identity unavailable: current user open_id is unavailable"
|
||||
}
|
||||
return identity, ""
|
||||
}
|
||||
|
||||
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
|
||||
if botInfo == nil {
|
||||
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
|
||||
}
|
||||
identity := meetingEventsIdentity{
|
||||
ID: botInfo.OpenID,
|
||||
Name: botInfo.AppName,
|
||||
ParticipantType: "bot",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
|
||||
out := meetingEventsMeeting{
|
||||
ID: common.GetString(meeting, "id"),
|
||||
Topic: common.GetString(meeting, "topic"),
|
||||
MeetingNo: common.GetString(meeting, "meeting_no"),
|
||||
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
|
||||
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
|
||||
Status: "unknown",
|
||||
}
|
||||
start, hasStart := parseFlexibleTime(out.StartTime)
|
||||
end, hasEnd := parseFlexibleTime(out.EndTime)
|
||||
if hasStart && !hasEnd {
|
||||
out.Status = "ongoing"
|
||||
}
|
||||
if hasStart && hasEnd {
|
||||
if end.After(start) {
|
||||
out.Status = "ended"
|
||||
} else {
|
||||
out.Status = "ongoing"
|
||||
out.EndTime = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
|
||||
if meeting == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
meeting.Status = "ended"
|
||||
if signal.HasEndTime {
|
||||
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
|
||||
var signal meetingEventsEndSignal
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil || meetingEventType(event) != "participant_left" {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
|
||||
item, _ := rawItem.(map[string]interface{})
|
||||
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
|
||||
continue
|
||||
}
|
||||
signal.Ended = true
|
||||
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||
if !ok {
|
||||
endTime, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
|
||||
signal.EndTime = endTime
|
||||
signal.HasEndTime = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
|
||||
payload := common.GetMap(event, "payload")
|
||||
out := meetingEventsEvent{
|
||||
EventID: common.GetString(event, "event_id"),
|
||||
EventType: meetingEventType(event),
|
||||
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
|
||||
Payload: payload,
|
||||
}
|
||||
out.Actors = eventActors(out.EventType, payload, selfIdentity)
|
||||
return out
|
||||
}
|
||||
|
||||
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
|
||||
var actors []meetingEventsIdentity
|
||||
addFromItems := func(key, participantKey string) {
|
||||
for _, raw := range common.GetSlice(payload, key) {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if participant := common.GetMap(item, participantKey); participant != nil {
|
||||
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
|
||||
}
|
||||
}
|
||||
}
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
addFromItems("participant_joined_items", "participant")
|
||||
case "participant_left":
|
||||
addFromItems("participant_left_items", "participant")
|
||||
case "transcript_received":
|
||||
addFromItems("transcript_received_items", "speaker")
|
||||
case "chat_received":
|
||||
addFromItems("chat_received_items", "operator")
|
||||
case "magic_share_started":
|
||||
addFromItems("magic_share_started_items", "operator")
|
||||
case "magic_share_ended":
|
||||
addFromItems("magic_share_ended_items", "operator")
|
||||
}
|
||||
return actors
|
||||
}
|
||||
|
||||
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
|
||||
identity := meetingEventsIdentity{
|
||||
ID: common.GetString(participant, "id"),
|
||||
Name: common.GetString(participant, "user_name"),
|
||||
ParticipantType: meetingEventsParticipantType(participant),
|
||||
Role: meetingEventsParticipantRole(participant),
|
||||
}
|
||||
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
|
||||
identity.ParticipantType = "bot"
|
||||
}
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
|
||||
identity.Role = "bot"
|
||||
}
|
||||
}
|
||||
if identity.ParticipantType == "" {
|
||||
identity.ParticipantType = "human"
|
||||
}
|
||||
if identity.Role == "" {
|
||||
identity.Role = "participant"
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsParticipantType(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsParticipantRole(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromUserType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "10", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromParticipantRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "host":
|
||||
return "host"
|
||||
case "2", "co_host", "cohost":
|
||||
return "co_host"
|
||||
case "3", "participant", "attendee":
|
||||
return "participant"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromEventUserRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "participant", "attendee":
|
||||
return "participant"
|
||||
case "2", "host":
|
||||
return "host"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "", "0":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func fieldValueString(values map[string]interface{}, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case string:
|
||||
return value
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func identityLabel(identity meetingEventsIdentity) string {
|
||||
name := identity.Name
|
||||
if name == "" {
|
||||
name = identity.ID
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
var tags []string
|
||||
if identity.ParticipantType != "" {
|
||||
tags = append(tags, identity.ParticipantType)
|
||||
}
|
||||
if identity.Role != "" && identity.Role != identity.ParticipantType {
|
||||
tags = append(tags, identity.Role)
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
|
||||
}
|
||||
|
||||
func meetingEventsTimeString(raw string) string {
|
||||
if parsed, ok := parseFlexibleTime(raw); ok {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
|
||||
rows := make([]interface{}, 0, len(events)+1)
|
||||
for _, event := range events {
|
||||
row := meetingEventsEventRow(event)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if metadata != nil {
|
||||
rows = append(rows, metadata)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
row["row_type"] = "event"
|
||||
return row
|
||||
}
|
||||
|
||||
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
|
||||
if data.Identity.Label != "" {
|
||||
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
|
||||
}
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
}
|
||||
|
||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||
if runtime.Bool("page-all") {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
sequence int
|
||||
group int
|
||||
subject string
|
||||
description string
|
||||
details []string
|
||||
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
|
||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
timeline := meetingTimeline{}
|
||||
var sequence int
|
||||
var group int
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||
}
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
|
||||
timeline.entries = append(timeline.entries, entry)
|
||||
}
|
||||
group++
|
||||
}
|
||||
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
|
||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||
left := timeline.entries[i]
|
||||
right := timeline.entries[j]
|
||||
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
return timeline
|
||||
}
|
||||
|
||||
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
|
||||
if timeline == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
if signal.HasEndTime {
|
||||
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
|
||||
timeline.endTime = signal.EndTime
|
||||
timeline.hasEnd = true
|
||||
return
|
||||
}
|
||||
timeline.hasEnd = false
|
||||
return
|
||||
}
|
||||
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
|
||||
timeline.hasEnd = false
|
||||
}
|
||||
}
|
||||
|
||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||
if timeline == nil || meeting == nil {
|
||||
return
|
||||
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
|
||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "participant_left":
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "transcript_received":
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "chat_received":
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
default:
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
|
||||
if text != "" {
|
||||
description = text
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
|
||||
} else {
|
||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if url != "" {
|
||||
details = append(details, "URL: "+url)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
|
||||
entry := meetingTimelineEntry{
|
||||
when: when,
|
||||
hasWhen: hasWhen,
|
||||
sequence: *sequence,
|
||||
group: group,
|
||||
subject: subject,
|
||||
description: description,
|
||||
details: details,
|
||||
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
|
||||
|
||||
func leaveAction(item map[string]interface{}) string {
|
||||
switch int(common.GetFloat(item, "leave_reason")) {
|
||||
case 2:
|
||||
case leaveReasonMeetingEnded:
|
||||
return "因会议结束离开了会议"
|
||||
case 3:
|
||||
case leaveReasonKicked:
|
||||
return "被移出了会议"
|
||||
default:
|
||||
return "离开了会议"
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "bot_001",
|
||||
"app_name": "Demo Bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoErrorStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "bot info unavailable",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-1",
|
||||
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"join_time": "2026-04-17T08:00:00Z",
|
||||
},
|
||||
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
|
||||
return event
|
||||
}
|
||||
|
||||
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-left",
|
||||
"event_type": "participant_left",
|
||||
"event_time": "2026-04-17T07:18:50Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "participant_left",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776410100",
|
||||
"end_time": "1776410100",
|
||||
},
|
||||
"participant_left_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"leave_time": "1776410330000",
|
||||
"leave_reason": leaveReason,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func chatReceivedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-2",
|
||||
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "第一条\n第二行",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "第二条",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mixedChatAndReactionEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-reaction",
|
||||
"event_type": "chat_received",
|
||||
"event_time": "2026-04-17T08:05:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "chat_received",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "OK",
|
||||
"message_type": 3,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
|
||||
"--start", "1710000000",
|
||||
"--end", "1710003600",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-all",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--page-all",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
|
||||
}
|
||||
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
|
||||
if got := len(events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if _, ok := event["summary"]; ok {
|
||||
t.Fatalf("event should not expose summary: %s", stdout.String())
|
||||
}
|
||||
if _, ok := event["raw"]; ok {
|
||||
t.Fatalf("event should not expose raw: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
||||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
||||
}
|
||||
if !strings.Contains(out, `"has_more":false`) {
|
||||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||||
}
|
||||
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
|
||||
`"role":"bot"`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"actors":[`,
|
||||
`"start_time":"2026-04-17T06:35:00Z"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"current_participants":`,
|
||||
`"is_self":`,
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoErrorStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"participant_joined"`,
|
||||
`"identity":{"participant_type":"bot","label":"bot"}`,
|
||||
`"warnings":[`,
|
||||
`identityunavailable`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
`"has_more":false`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
t.Fatalf("user json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := common.GetMap(envelope, "data")
|
||||
meeting := common.GetMap(data, "meeting")
|
||||
if got := common.GetString(meeting, "status"); got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
|
||||
}
|
||||
if _, ok := meeting["end_time"]; ok {
|
||||
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
|
||||
t.Fatalf("meeting end_time = %q, want leave time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonUserLeft),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
timeline := buildMeetingEventTimeline([]interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
})
|
||||
got := renderMeetingEventsPretty(timeline)
|
||||
|
||||
if strings.Contains(got, "进行中") {
|
||||
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "会议时间:2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
|
||||
t.Fatalf("pretty output missing derived meeting end window: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantJoinedEventOngoing(),
|
||||
participantJoinedEvent(),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
|
||||
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
|
||||
}
|
||||
if got := len(out.Events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "unknown" {
|
||||
t.Fatalf("meeting status = %q, want unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
|
||||
got := meetingEventsMeetingFromPayload(map[string]interface{}{
|
||||
"id": "m1",
|
||||
"start_time": "1776410100",
|
||||
})
|
||||
|
||||
if got.Status != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got.Status)
|
||||
}
|
||||
if got.StartTime != "2026-04-17T07:15:00Z" {
|
||||
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
|
||||
}
|
||||
if got.EndTime != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "ndjson",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
|
||||
t.Fatalf("first ndjson row should be event: %s", lines[0])
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(lines[0], unwanted) {
|
||||
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"row_type":"metadata"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"identity":`,
|
||||
} {
|
||||
if !strings.Contains(lines[1], want) {
|
||||
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
|
||||
rows := meetingEventsEventRows([]meetingEventsEvent{
|
||||
{EventType: "unknown_event"},
|
||||
}, nil)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
row, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("row type = %T, want map", rows[0])
|
||||
}
|
||||
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
|
||||
if _, exists := row[unwanted]; exists {
|
||||
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
|
||||
}
|
||||
}
|
||||
if got := row["row_type"]; got != "event" {
|
||||
t.Fatalf("row_type = %v, want event", got)
|
||||
}
|
||||
if got := row["event_type"]; got != "unknown_event" {
|
||||
t.Fatalf("event_type = %v, want unknown_event", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, `"message_type": 3`) {
|
||||
if !strings.Contains(out, `"message_type": 1`) {
|
||||
t.Fatalf("json output should keep numeric fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"chat_received"`,
|
||||
`"chat_received_items":[`,
|
||||
`"content":"OK"`,
|
||||
`"message_type":3`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"im_post"`) {
|
||||
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"当前身份:Demo Bot [bot]",
|
||||
"会议主题:项目例会",
|
||||
"会议时间:2026-04-17 15:15:00(进行中)",
|
||||
"Demo Bot(bot_001) 加入了会议",
|
||||
"Alice(u1): [reaction] 第一条\\n第二行",
|
||||
"Alice(u1): [reaction] 第二条",
|
||||
"Alice(u1): [text] 第一条\\n第二行",
|
||||
"Alice(u1): [text] 第二条",
|
||||
"Bob(u2) 开始共享「共享文档」",
|
||||
"URL: https://example.com/doc",
|
||||
"page_token: 1710000000000000000",
|
||||
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
|
||||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub(nil, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
|
||||
item map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 2,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" || got.Role != "host" {
|
||||
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.Role != "participant" {
|
||||
t.Fatalf("identity = %#v, want role=participant", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "ou_app",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 10,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "bot" {
|
||||
t.Fatalf("identity = %#v, want participant_type=bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u_unknown",
|
||||
"user_name": "Unknown",
|
||||
"user_type": 0,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "unknown" {
|
||||
t.Fatalf("identity = %#v, want participant_type=unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"type": "bot",
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" {
|
||||
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
|
||||
event := participantJoinedEvent()
|
||||
delete(event, "event_type")
|
||||
|
||||
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
|
||||
if got.EventType != "participant_joined" {
|
||||
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
|
||||
}
|
||||
if len(got.Actors) != 1 {
|
||||
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
|
||||
}
|
||||
if got.Actors[0].ID != "bot_001" {
|
||||
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapePrettyText(t *testing.T) {
|
||||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||||
want := `line1\nline2\t\r\u0007`
|
||||
|
||||
@@ -199,6 +199,19 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != opaqueNodeToken {
|
||||
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -224,11 +237,6 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -351,10 +359,11 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -365,7 +374,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wik_parent",
|
||||
"parent_node_token": parentNodeToken,
|
||||
"node_type": "origin",
|
||||
"title": "Child Doc",
|
||||
"has_child": false,
|
||||
@@ -378,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -400,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
if len(envelope.Data.Nodes) != 1 {
|
||||
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
|
||||
}
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
|
||||
},
|
||||
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
if spec.ObjType == "" {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
|
||||
@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
|
||||
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,11 +207,6 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
@@ -586,15 +585,11 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := core.Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := core.Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
|
||||
@@ -12,6 +12,16 @@ metadata:
|
||||
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||
|
||||
## 身份与一次性授权
|
||||
|
||||
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
|
||||
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
|
||||
|
||||
## 意图路由
|
||||
|
||||
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义,**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`。**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
|
||||
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
||||
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
||||
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
||||
|
||||
@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`;CLI 会在写入前读取文件并展开为内联内容。
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`;PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`。
|
||||
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
|
||||
@@ -73,12 +73,14 @@ metadata:
|
||||
- 再根据 `note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
|
||||
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md))
|
||||
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
|
||||
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。
|
||||
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
|
||||
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
|
||||
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
|
||||
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings,并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`。
|
||||
6. 命令默认输出结构化事件契约:`meeting`、`identity`、`events`、`warnings`、`has_more`、`page_token`;`identity` 表示当前读取身份,事件 actor 含 `participant_type`、`role` 和可读 `label`,事件细节保留在 `payload`。
|
||||
7. 输出格式默认优先 `--format pretty`(时间线更易读,并带当前身份标签);需要稳定字段做结构化处理时用 `--format json`;需要流式消费事件时用 `--format ndjson`。
|
||||
8. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
|
||||
9. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
|
||||
10. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
11. **会中聊天 / 互动转发到 IM 时基于 JSON 事件构造 IM post。** `chat_received_items[].message_type == 3` 表示会中 reaction;构造 IM post 时,先用 [`lark-im` reaction emoji 白名单](../lark-im/references/lark-im-reactions.md) 判断同一 item 的 `content`:白名单内才写成 Feishu post `emotion` 节点,不在白名单内则保留原始 key 并写成文本节点,例如 `[CanNotSee]`。普通聊天按文本发送。不要从 pretty/Markdown 重新拼消息,也不要把整条消息退化成纯文本;只降级非法 reaction key。用户已说“发给我 / 推送给我 / 发到我的单聊”时,默认用 bot 身份直接发当前用户;收件人不明确时只补问收件人。
|
||||
12. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
|
||||
13. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings,并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`。
|
||||
|
||||
### 3. 发送会中文本或会中表情(写操作)
|
||||
|
||||
@@ -119,13 +121,14 @@ lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
|
||||
AS=bot
|
||||
JOIN=$(lark-cli vc +meeting-join --as "$AS" --meeting-number 123456789 --format json)
|
||||
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
|
||||
# 2. 会中轮询事件
|
||||
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
|
||||
# 沿用入会身份;默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
|
||||
# 典型间隔 10-30 秒
|
||||
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as "$AS" --meeting-id "$MID" --page-all --format pretty
|
||||
|
||||
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_id / minute_token 决策读取
|
||||
lark-cli vc +detail --meeting-ids "$MID"
|
||||
@@ -137,7 +140,7 @@ lark-cli vc +detail --meeting-ids "$MID"
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:
|
||||
|
||||
@@ -14,17 +14,14 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 默认用法:全量拉取当前可见事件
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
|
||||
# 默认用法:全量拉取当前身份可见事件;输出易读时间线
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty
|
||||
|
||||
# 指定时间范围,并拉全该时间窗内当前可见事件
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
|
||||
# 基于上一次保存的 page_token 继续查新增事件
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
|
||||
|
||||
# 调试或控制返回体大小时,显式只查一页
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <last_page_token> --page-all --format pretty
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -54,9 +51,10 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
|
||||
|
||||
### 2. 身份来源是读取事件的权限锚点
|
||||
|
||||
- 用户身份路径:先用 `+meeting-list-active --as user` 发现当前登录用户的会议,再用 `+meeting-events --as user` 读取该 `meeting_id`。
|
||||
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接用 `--as bot` 查。
|
||||
- 不要混用身份。身份不一致时,常见结果是空列表、`no permission` 或 `bot is not in meeting`。
|
||||
- `+meeting-events` 支持 `--as user` 和 `--as bot`。
|
||||
- 用户身份路径:用户身份发现的会议继续用用户身份读取。
|
||||
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接查。
|
||||
- 不要在拿到 `meeting_id` 后随意切换身份。身份不一致时,常见结果是空列表、`no permission` 或 `bot is not in meeting`。
|
||||
|
||||
### 3. 读取事件前必须先拿到可见的 meeting_id
|
||||
|
||||
@@ -67,21 +65,21 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 再查询事件
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id>
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id>
|
||||
```
|
||||
|
||||
如果应用机器人已经在会中,也可以先通过 active meeting 找会:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
如果只是查询当前登录用户所在会议:
|
||||
如果要查询当前登录用户所在会议:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报:
|
||||
@@ -104,18 +102,19 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
|
||||
执行准则:
|
||||
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format pretty`
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty`
|
||||
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
|
||||
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
### 5. pretty / json 输出差异
|
||||
### 5. 输出格式差异
|
||||
|
||||
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式。
|
||||
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里,适合提取字段、联动其他命令或做进一步程序处理。
|
||||
- `--format json`:结构化契约,顶层包含 `meeting`、`identity`、`events`、`has_more`、`page_token`。`identity` 表示当前读取身份;事件 actor 统一含 `participant_type`、`role`、`label`;每条事件保留 `payload` 便于追溯细节。
|
||||
- `--format pretty`:默认推荐格式,输出当前身份和逐条时间线,适合快速理解“发生了什么”。
|
||||
- `--format ndjson`:输出事件行,并带 metadata 行,适合流式消费。
|
||||
|
||||
**选型原则**:只要目标是告诉用户“发生了什么”,默认就用 `--page-all --format pretty`;只有在需要完整原始消息流和结构化字段时,才改用 `json`。
|
||||
**选型原则**:只在 `pretty`、`json`、`ndjson` 之间选择。目标是告诉用户“发生了什么”时,用 `--page-all --format pretty`;需要稳定字段给 agent 做结构化消费、总结、转发或二次处理时用 `--format json`;需要流式消费时用 `--format ndjson`。
|
||||
|
||||
> **注意**:pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
|
||||
|
||||
@@ -132,10 +131,10 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
|
||||
执行准则:
|
||||
|
||||
- 如果上下文已有明确 `meeting_id` 和来源身份,直接用同一身份执行 `+meeting-events --page-all --format json`。
|
||||
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format pretty`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format pretty`。返回多个会议时先让用户选择。
|
||||
- 如果上下文已有明确 `meeting_id`,沿用该 `meeting_id` 的来源身份执行 `+meeting-events --page-all --format json`。
|
||||
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format json`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json`。返回多个会议时先让用户选择。
|
||||
- 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`。
|
||||
- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
|
||||
- 这类问题拿到 `meeting_id` 后,用同一身份执行 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format json` 拉取最新事件流。
|
||||
- 如果事件中出现共享文档线索,例如:
|
||||
- `magic_share_started`
|
||||
- `share_doc.title`
|
||||
@@ -159,7 +158,10 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `events` | 事件列表 |
|
||||
| `meeting` | 会议身份与时间状态,包含 `id/topic/meeting_no/start_time/end_time/status` |
|
||||
| `identity` | 当前读取身份,包含 `id/name/participant_type/label` |
|
||||
| `events` | 结构化事件列表;每条事件含参与者 `actors` 和事件细节 `payload` |
|
||||
| `warnings` | 非阻断告警列表;事件列表本身仍可使用 |
|
||||
| `has_more` | 是否还有下一页 |
|
||||
| `page_token` | 下一页游标 |
|
||||
|
||||
@@ -174,6 +176,32 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
| `magic_share_started` | 开始共享内容 / 文档 |
|
||||
| `magic_share_ended` | 结束共享 |
|
||||
|
||||
### Forwarding meeting chat and reactions to IM
|
||||
|
||||
转发到 IM 时,Agent 必须先用 `+meeting-events --format json` 的结构化事件构造完整 Feishu `post` 内容,再调用 IM 发送 shortcut。不要解析 pretty/Markdown 输出,也不要先生成纯文本或 Markdown 后再期望 IM 侧二次识别 reaction。
|
||||
|
||||
对 `event_type == "chat_received"` 的事件逐项处理 `payload.chat_received_items`:
|
||||
|
||||
- `message_type == 3` 是会中 reaction;构造 IM `post` 内容时,以 [`lark-im` reaction emoji 列表](../../lark-im/references/lark-im-reactions.md) 作为 IM `emotion` 白名单。白名单内的 key 写成 `{"tag":"emotion","emoji_type":"<content>"}`,例如 `JIAYI`、`THUMBSUP`、`OK`。
|
||||
- 对不在 IM reaction emoji 白名单内的 reaction key,保留原始 key 但写成文本节点,例如 `{"tag":"text","text":"[<content>]"}`;不应直接写入 `emotion.emoji_type`,否则 IM 发送会失败。
|
||||
- 不要大小写归一化或猜测映射;`content` 是原始 reaction key,必须原样判断。
|
||||
- 其他聊天消息写成文本节点:`{"tag":"text","text":"<content>"}`。
|
||||
- 最终调用 `im +messages-send --msg-type post --content '<post-json>'`,其中 `<post-json>` 应混合使用可渲染 `emotion` 节点和文本 fallback;不要用 `--markdown` 承载会中 reaction。
|
||||
- 如果 IM 返回 `message_content_emotion_tag's emoji_type is invalid`,只降级非法 reaction key,不要把整条消息退化成纯文本。
|
||||
- 如果用户原始请求已经明确“发给我 / 推送给我 / 发到我的聊天框 / 发到我的单聊”,这已经覆盖本次收件人、内容和发送动作,直接发送给当前用户,不要再二次询问“是否发送”。
|
||||
- 默认用应用身份 `--as bot` 发送;只有用户明确要求“用本人身份 / 用户身份发送”时才切到 `--as user`。
|
||||
- 如果用户要求发给某个群或其他人但收件人不可唯一确定,只询问缺失的收件人信息。
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-events \
|
||||
--as <same_identity> \
|
||||
--meeting-id <id> \
|
||||
--page-all \
|
||||
--format json
|
||||
```
|
||||
|
||||
如果用户已经要求“发给我”,`<open_id>` 使用当前用户的 open_id;需要解析时先用用户查询能力获取当前用户信息。构造 IM post 时只发送用户请求范围内的会中内容,不要把前一条自然语言预览当作发送内容。
|
||||
|
||||
## pretty 输出示例
|
||||
|
||||
```text
|
||||
@@ -197,28 +225,29 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:入会后查看会中发生了什么
|
||||
### 场景 1:入会后读取会中发生了什么
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
|
||||
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
|
||||
# 第 2 步:查询事件流
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
|
||||
# 第 2 步:用 meeting.id 读取当前可见事件
|
||||
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 1b:应用机器人已在会中,先发现 meeting_id 再读事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 1c:当前登录用户正在会中,先发现 meeting_id 再读事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 2:过滤某段时间内的事件
|
||||
@@ -226,7 +255,7 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
|
||||
```bash
|
||||
lark-cli vc +meeting-events \
|
||||
--as <same_identity> \
|
||||
--meeting-id <meeting.id> \
|
||||
--meeting-id <id> \
|
||||
--start 2026-04-17T15:00:00+08:00 \
|
||||
--end 2026-04-17T16:00:00+08:00 \
|
||||
--page-all \
|
||||
@@ -240,7 +269,7 @@ lark-cli vc +meeting-events \
|
||||
# 这次直接从该游标继续拉新增事件
|
||||
lark-cli vc +meeting-events \
|
||||
--as <same_identity> \
|
||||
--meeting-id <meeting.id> \
|
||||
--meeting-id <id> \
|
||||
--page-token <last_page_token> \
|
||||
--page-all \
|
||||
--format pretty
|
||||
@@ -257,10 +286,9 @@ lark-cli vc +meeting-events \
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
|
||||
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active` 按 `meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
|
||||
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
|
||||
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果 `meeting_id` 来自用户身份发现,改回 `--as user`;如果确实要应用身份读取,先让应用机器人入会或确认它曾参会后再用 `--as bot`。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
|
||||
| 用户身份无权限 / 不可见 | 当前用户不是该会议的可见参与者,或 `meeting_id` 不是从用户身份路径获得 | 不要反复执行 `auth login`。先确认 `meeting_id` 是否来自 `+meeting-list-active --as user`;如果用户明确要切到应用身份,再通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 |
|
||||
|
||||
@@ -29,7 +29,7 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
|
||||
| 用户身份 | `--as user` | 当前登录用户正在参加的会议 | 继续 `+meeting-events --as user` |
|
||||
| 应用身份 | `--as bot --user-id <user_open_id>` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+meeting-events --as bot` |
|
||||
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用户身份拿到的 `meeting_id` 改用应用身份查,也不要把应用身份拿到的 `meeting_id` 改用用户身份查,除非用户明确要求切换场景。
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把应用身份拿到的 `meeting_id` 改用用户身份读事件,也不要把用户身份拿到的 `meeting_id` 强制切到应用身份。
|
||||
|
||||
应用身份返回空,不代表目标用户不在任何会议中,只能说明没有找到“目标用户在会中且应用机器人也在会中”的当前会。
|
||||
|
||||
@@ -38,22 +38,22 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
|
||||
```bash
|
||||
# 方式 1:先让应用机器人入会,直接从 join 响应拿 meeting.id
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
|
||||
|
||||
# 方式 2:应用机器人已经在会中时,用应用身份发现 meeting_id
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
|
||||
|
||||
# 方式 3:只回答当前登录用户所在会议发生了什么
|
||||
# 方式 3:查询当前登录用户所在会议发生了什么
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
|
||||
```
|
||||
|
||||
## 多会议选择
|
||||
|
||||
- 如果返回多个会议,不要自动挑第一个。
|
||||
- 向用户展示每个候选的 `meeting_title` / `meeting_no` / `meeting_id`,等待用户选择。
|
||||
- 选择后继续使用发现该会议时的同一身份调用 `+meeting-events`。
|
||||
- 选择后用同一身份执行 `+meeting-events` 读取事件。
|
||||
|
||||
## 9 位会议号匹配
|
||||
|
||||
@@ -80,7 +80,7 @@ lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
|---------|---------|---------|
|
||||
| `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id |
|
||||
| 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 |
|
||||
| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id,再执行 `+meeting-list-active --as bot --user-id <user_open_id>`;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
|
||||
| 用户身份无权限 / 不可见 | 当前登录用户没有可见的进行中会议,或当前身份无法读取该会议 | 不要反复执行 `auth login`。先确认当前登录用户是否在会中、是否切错 profile;如果用户明确要查询应用机器人可见的会议,再拿目标用户 open_id 执行 `+meeting-list-active --as bot --user-id <user_open_id>`,并按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
|
||||
| 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 |
|
||||
| `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
|
||||
@@ -19,7 +19,7 @@ lark-cli wiki +node-get \
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. |
|
||||
| `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. |
|
||||
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
|
||||
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from typed Lark URLs. If omitted for a raw token, the shortcut treats it as a wiki `node_token`. |
|
||||
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
|
||||
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
|
||||
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
)
|
||||
|
||||
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式
|
||||
// (?transactional=false),sql body 由 --sql 透传,默认 env=dev。
|
||||
// (?transactional=false),sql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)。
|
||||
func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
|
||||
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -37,8 +37,8 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
"CLI is DBA mode → must send transactional=false in query")
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
|
||||
"transactional should be in query, not body")
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
||||
"default env must be dev (not production)")
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
|
||||
"default: no --environment → env key must be omitted (server picks workspace default branch)")
|
||||
})
|
||||
|
||||
t.Run("OnlineEnvSwitch", func(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
|
||||
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -32,7 +32,8 @@ func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
|
||||
"default: no --environment → env key must be omitted (server picks workspace default branch)")
|
||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
||||
"empty page_token must be omitted")
|
||||
|
||||
46
tests/cli_e2e/vc/vc_meeting_events_dryrun_test.go
Normal file
46
tests/cli_e2e/vc/vc_meeting_events_dryrun_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestVCMeetingEventsDryRun(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-token", "1710000000000000000",
|
||||
"--page-size", "40",
|
||||
"--start", "1710000000",
|
||||
"--end", "1710003600",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/bots/events", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "7628568141510692381", gjson.Get(out, "api.0.params.meeting_id").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "1710000000000000000", gjson.Get(out, "api.0.params.page_token").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "40", gjson.Get(out, "api.0.params.page_size").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "1710000000", gjson.Get(out, "api.0.params.start_time").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "1710003600", gjson.Get(out, "api.0.params.end_time").String(), "stdout:\n%s", out)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestVCMeetingMessageSendDryRun(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -81,7 +81,7 @@ func TestVCMeetingMessageSendDryRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
setVCDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -104,10 +104,10 @@ func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
|
||||
require.Empty(t, result.Stdout)
|
||||
}
|
||||
|
||||
func setVCMeetingMessageSendDryRunEnv(t *testing.T) {
|
||||
func setVCDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_meeting_message_send_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_meeting_message_send_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
|
||||
// runtime overlay in internal/registry unconditionally applies it.
|
||||
const seededCatalogVersion = "9.9.9"
|
||||
|
||||
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
|
||||
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
|
||||
// gives the runtime catalog real data WITHOUT any network, so a test can prove
|
||||
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
|
||||
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
|
||||
const seededCatalogJSON = `{
|
||||
"version": "9.9.9",
|
||||
"services": [
|
||||
{
|
||||
"name": "plugine2e",
|
||||
"version": "v1",
|
||||
"title": "plugin_e2e synthetic service",
|
||||
"description": "synthetic fixture for the runtime-catalog test; not a real API",
|
||||
"servicePath": "/open-apis/plugine2e/v1",
|
||||
"resources": {
|
||||
"widgets": {
|
||||
"methods": {
|
||||
"get": {
|
||||
"id": "plugine2e.widgets.get",
|
||||
"path": "/open-apis/plugine2e/v1/widgets/:id",
|
||||
"httpMethod": "GET",
|
||||
"description": "synthetic read method",
|
||||
"risk": "read",
|
||||
"accessTokens": ["tenant"],
|
||||
"parameters": {
|
||||
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
|
||||
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
|
||||
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
|
||||
// meta is left ON so the on-disk cache overlay is consulted, but a long
|
||||
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
|
||||
// bare-module binary that has runtime metadata available from a warm cache.
|
||||
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
|
||||
t.Helper()
|
||||
cfg := t.TempDir()
|
||||
cacheDir := filepath.Join(cfg, "cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cache dir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
|
||||
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
|
||||
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
|
||||
env := append(os.Environ(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
|
||||
"LARKSUITE_CLI_META_TTL=1000000",
|
||||
)
|
||||
return runWithEnv(t, bin, env, args...)
|
||||
}
|
||||
|
||||
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
|
||||
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
|
||||
// out-of-domain, so any failure the command produces below is the command's
|
||||
// own behavior against the empty stub catalog, not a policy denial.
|
||||
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("plain", "0.1.0").
|
||||
Observer(platform.After, "noop", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
|
||||
// The clean tree embeds only the empty meta_data_default.json stub
|
||||
// (internal/registry/catalog.go's SchemaCatalog falls through to
|
||||
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
|
||||
// environment disables the remote overlay fetch and points the cache dir at
|
||||
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
|
||||
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
|
||||
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
|
||||
//
|
||||
// Observed real output for both `schema` and `schema im.messages.reply`
|
||||
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
|
||||
//
|
||||
// exit=2
|
||||
// stdout=(empty)
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"No API metadata available",
|
||||
// "hint":"this binary has no embedded API metadata; run any command with
|
||||
// network access to the open platform once so metadata can be fetched and
|
||||
// cached"}}
|
||||
//
|
||||
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
|
||||
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
|
||||
// error with an actionable hint, NOT the raw Go panic crash that
|
||||
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
|
||||
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
|
||||
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
|
||||
// exits 2. The two real discriminators against a crash are the absence of a
|
||||
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
|
||||
// are asserted below.
|
||||
func TestDegradeStubMetadataSchema(t *testing.T) {
|
||||
bin := buildFork(t, "plain", plainPlugin)
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"schema root", []string{"schema"}},
|
||||
{"schema with path", []string{"schema", "im.messages.reply"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := run(t, bin, tc.args...)
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if strings.Contains(res.stderr, "panic:") {
|
||||
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
|
||||
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
|
||||
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
|
||||
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
|
||||
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
|
||||
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
|
||||
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
|
||||
// falls back to the merged runtime catalog and the lookup succeeds.
|
||||
//
|
||||
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
|
||||
// cold-cache corner (no runtime data -> graceful "No API metadata available");
|
||||
// this one pins the warm-cache main path (runtime data present -> schema works),
|
||||
// so a regression that re-embeds the embedded-only lookup fails HERE with
|
||||
// "Unknown service" rather than silently passing.
|
||||
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
|
||||
bin := buildFork(t, "plain", plainPlugin)
|
||||
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
out := res.stdout + res.stderr
|
||||
if strings.Contains(out, "Unknown service") {
|
||||
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
|
||||
}
|
||||
if strings.Contains(out, "No API metadata available") {
|
||||
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
|
||||
}
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !strings.Contains(out, "plugine2e") {
|
||||
t.Errorf("schema output does not mention the seeded service; out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
|
||||
// (and ResolveToken) unconditionally return a *credential.BlockError.
|
||||
// internal/credential/credential_provider.go's doResolveAccount returns this
|
||||
// error straight from the provider loop -- before any defaultAcct fallback
|
||||
// and, transitively, before the LarkClient/HttpClient phases that would issue
|
||||
// a real network call ever run (see internal/cmdutil/factory_default.go's
|
||||
// Phase 2 -> Phase 4 ordering).
|
||||
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
type blockProvider struct{}
|
||||
|
||||
func (blockProvider) Name() string { return "block-cred" }
|
||||
|
||||
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
|
||||
}
|
||||
|
||||
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(blockProvider{})
|
||||
}
|
||||
`
|
||||
|
||||
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
|
||||
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
|
||||
// separate `go test -count` invocations (byte-identical both times, unlike
|
||||
// the transport-abort case -- credential resolution happens once, before any
|
||||
// endpoint is chosen, so there is no varying destination URL to leak into the
|
||||
// message):
|
||||
//
|
||||
// exit=5
|
||||
// stdout=(empty)
|
||||
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
|
||||
// "message":"blocked by block-cred: blocked for test"}}
|
||||
func TestSubsystemCredentialBlock(t *testing.T) {
|
||||
bin := buildFork(t, "credential-block", credentialBlockPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit != 5 {
|
||||
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
|
||||
t.Errorf("error.type=%q want internal", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
|
||||
t.Errorf("error.subtype=%q want unknown", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
|
||||
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
|
||||
// diagnostic commands on a fork carrying the readonly Restrict rule:
|
||||
// - `config policy show`: source_name == the plugin name that installed the
|
||||
// active rule.
|
||||
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
|
||||
// "hooks":{...}}],"total":N} with the readonly plugin present.
|
||||
func TestDiagnostics(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
|
||||
pol := run(t, bin, "config", "policy", "show")
|
||||
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
|
||||
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
|
||||
}
|
||||
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
|
||||
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
|
||||
}
|
||||
|
||||
plug := run(t, bin, "config", "plugins", "show")
|
||||
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
|
||||
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
|
||||
}
|
||||
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
|
||||
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
|
||||
}
|
||||
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
|
||||
t.Errorf("plugins.0.name=%q want readonly", name)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package plugin_e2e exercises the extension/platform plugin contract the way a
|
||||
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
|
||||
// then runs that fork as a subprocess and asserts the real stderr/stdout
|
||||
// envelopes and exit codes. This is L4 coverage — the in-process unit and
|
||||
// integration tests (extension/..., cmd/...) assert Go error values in the test
|
||||
// process and structurally cannot observe envelope serialization, exit codes, or
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain.
|
||||
//
|
||||
// Mechanism (the "customer build", mirrors xcaddy's build mode):
|
||||
// 1. `git archive HEAD` a clean tree containing only committed files (so the
|
||||
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
|
||||
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
|
||||
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
|
||||
// + plugin package (its init() calls platform.Register).
|
||||
// 3. `go build` the fork (offline-capable via the warm module cache), then run
|
||||
// it as a subprocess and assert.
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cleanTree is the git-archived, committed-only source tree of the repo under
|
||||
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
|
||||
// TestMain must live in a _test.go file to be recognized by `go test`, so the
|
||||
// entry point sits there while the rest of the harness mechanism lives here.
|
||||
var cleanTree string
|
||||
|
||||
// baseDir holds the archive tree plus every generated customer module.
|
||||
var baseDir string
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working directory
|
||||
// (which `go test` sets to the package dir, tests/plugin_e2e).
|
||||
func repoRoot() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
|
||||
// into `tar -x`. Only tracked files are included — gitignored build artifacts
|
||||
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
|
||||
// would see them. It wires the two processes with an explicit pipe rather than a
|
||||
// shell, so dst never reaches a shell command line.
|
||||
func gitArchive(root, dst string) error {
|
||||
archive := exec.Command("git", "archive", "HEAD")
|
||||
archive.Dir = root
|
||||
extract := exec.Command("tar", "-x", "-C", dst)
|
||||
pipe, err := archive.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extract.Stdin = pipe
|
||||
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
|
||||
// per command, so a shared strings.Builder would be written concurrently by
|
||||
// both (git archive and tar run in parallel) -- a data race, since
|
||||
// strings.Builder is not concurrency-safe.
|
||||
var archiveErr, extractErr strings.Builder
|
||||
archive.Stderr = &archiveErr
|
||||
extract.Stderr = &extractErr
|
||||
if err := extract.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := archive.Run(); err != nil {
|
||||
_ = extract.Wait()
|
||||
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
|
||||
}
|
||||
if err := extract.Wait(); err != nil {
|
||||
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// builtForks caches fork binaries by name so identical forks are built once.
|
||||
var builtForks = map[string]string{}
|
||||
|
||||
// buildFork generates a customer module whose plugin package body is pluginSrc,
|
||||
// builds the fork, and returns the binary path. Forks are cached by name.
|
||||
func buildFork(t *testing.T, name, pluginSrc string) string {
|
||||
t.Helper()
|
||||
if bin, ok := builtForks[name]; ok {
|
||||
return bin
|
||||
}
|
||||
mod := filepath.Join(baseDir, "fork-"+name)
|
||||
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir customer module: %v", err)
|
||||
}
|
||||
|
||||
// go.mod: reuse cli's require graph, rename the module, replace cli with the
|
||||
// local archived tree. This avoids `go mod tidy` (no network at test time).
|
||||
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.mod: %v", err)
|
||||
}
|
||||
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
|
||||
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
|
||||
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
|
||||
|
||||
// go.sum: transitive dependency hashes are identical to cli's.
|
||||
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.sum: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
|
||||
|
||||
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
|
||||
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
|
||||
|
||||
bin := filepath.Join(mod, "fork-bin")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Dir = mod
|
||||
// -mod=mod fixes require annotations copied from cli's go.mod; the default
|
||||
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
|
||||
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
|
||||
}
|
||||
builtForks[name] = bin
|
||||
return bin
|
||||
}
|
||||
|
||||
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
`
|
||||
|
||||
// result is a subprocess run outcome.
|
||||
type result struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exit int
|
||||
}
|
||||
|
||||
// run executes the fork binary with args in an isolated, offline environment and
|
||||
// captures stdout/stderr/exit. Each call gets a fresh empty
|
||||
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
|
||||
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
|
||||
// open platform. That reproduces the bare-module customer state (no embedded
|
||||
// metadata, cold cache) deterministically on any machine, including CI: without
|
||||
// it, whether a command's assertion is reached depends on whether a live network
|
||||
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
|
||||
// via runWithSeededCatalog.
|
||||
func run(t *testing.T, bin string, args ...string) result {
|
||||
t.Helper()
|
||||
return runWithEnv(t, bin, isolatedEnv(t), args...)
|
||||
}
|
||||
|
||||
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
|
||||
// base) by runWithSeededCatalog.
|
||||
func isolatedEnv(t *testing.T) []string {
|
||||
t.Helper()
|
||||
return append(os.Environ(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
|
||||
"LARKSUITE_CLI_REMOTE_META=off",
|
||||
)
|
||||
}
|
||||
|
||||
// runWithEnv runs bin as a subprocess with the given full environment, capturing
|
||||
// stdout/stderr/exit.
|
||||
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(ctx, bin, args...)
|
||||
c.Env = env
|
||||
var stdout, stderr strings.Builder
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("run %v: %v", args, err)
|
||||
}
|
||||
}
|
||||
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// multipleRestrictPlugin registers TWO distinct plugins that each call
|
||||
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
|
||||
// more than one distinct Restrict-owner regardless of each rule's own
|
||||
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
|
||||
// before ValidateRule).
|
||||
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-a", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "a-rule",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-b", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "b-rule",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
|
||||
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
|
||||
// every RunE in the tree so it fires regardless of which command runs):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"multiple plugins called Restrict; only one plugin may own the
|
||||
// policy: [restrict-a restrict-b]",
|
||||
// "hint":"plugin policy configuration is broken (reason_code
|
||||
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
|
||||
// conflicting plugin"}}
|
||||
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
|
||||
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
|
||||
}
|
||||
|
||||
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
|
||||
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
|
||||
// Registrar validate Rule *contents* (only nilness) -- semantic validation
|
||||
// happens later, in cmdpolicy.ValidateRule, called from
|
||||
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
|
||||
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("invalid-rule", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bad-risk",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.Risk("bogus"),
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
|
||||
// must be one of read|write|high-risk-write",
|
||||
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
|
||||
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
|
||||
func TestInstallInvalidRulePin(t *testing.T) {
|
||||
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "invalid_rule")
|
||||
}
|
||||
|
||||
// installFailedPlugin is a hand-written bare platform.Plugin (not
|
||||
// Builder-based -- Install returning a plain error is not expressible
|
||||
// through the Builder's fluent API) whose Install always returns an error.
|
||||
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
|
||||
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type installFailed struct{}
|
||||
|
||||
func (installFailed) Name() string { return "install-failed" }
|
||||
func (installFailed) Version() string { return "0.1.0" }
|
||||
func (installFailed) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installFailed) Install(r platform.Registrar) error {
|
||||
return errors.New("deliberate install failure")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installFailed{}) }
|
||||
`
|
||||
|
||||
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-failed\" (install_failed): Install returned
|
||||
// error: deliberate install failure",
|
||||
// "hint":"plugin \"install-failed\" failed to install (reason_code
|
||||
// install_failed); fix or remove the plugin before running commands"}}
|
||||
func TestInstallFailedPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-failed", installFailedPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_failed")
|
||||
}
|
||||
|
||||
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
|
||||
// safeCallInstall (internal/platform/host.go) recovers and converts the
|
||||
// panic into a typed install_panic error rather than crashing the binary.
|
||||
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type installPanic struct{}
|
||||
|
||||
func (installPanic) Name() string { return "install-panic" }
|
||||
func (installPanic) Version() string { return "0.1.0" }
|
||||
func (installPanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installPanic) Install(r platform.Registrar) error {
|
||||
panic("deliberate install panic")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
|
||||
// deliberate install panic",
|
||||
// "hint":"plugin \"install-panic\" failed to install (reason_code
|
||||
// install_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-panic", installPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_panic")
|
||||
}
|
||||
|
||||
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
|
||||
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
|
||||
// Capabilities(), so this aborts unconditionally regardless of what
|
||||
// Capabilities() would have declared (host.go's isUntrustedConfigError
|
||||
// list) -- Capabilities() here is a throwaway zero value, never invoked.
|
||||
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type pluginNamePanic struct{}
|
||||
|
||||
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
|
||||
func (pluginNamePanic) Version() string { return "0.1.0" }
|
||||
func (pluginNamePanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{}
|
||||
}
|
||||
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(pluginNamePanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
|
||||
// output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
|
||||
// panicked: deliberate name panic",
|
||||
// "hint":"plugin \"<unknown>\" failed to install (reason_code
|
||||
// plugin_name_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPluginNamePanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
|
||||
}
|
||||
|
||||
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
|
||||
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
|
||||
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
|
||||
// recover leaves the pre-set FailClosed default in place -- so this aborts
|
||||
// unconditionally too, without the plugin ever declaring a real policy.
|
||||
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type capabilitiesPanic struct{}
|
||||
|
||||
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
|
||||
func (capabilitiesPanic) Version() string { return "0.1.0" }
|
||||
func (capabilitiesPanic) Capabilities() platform.Capabilities {
|
||||
panic("deliberate capabilities panic")
|
||||
}
|
||||
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(capabilitiesPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
|
||||
// real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
|
||||
// Plugin.Capabilities() panicked: deliberate capabilities panic",
|
||||
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
|
||||
// capabilities_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallCapabilitiesPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "capabilities_panic")
|
||||
}
|
||||
|
||||
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
|
||||
// Capabilities.Restricts=true (paired with the required FailClosed) but
|
||||
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
|
||||
// (internal/platform/staging.go) checks this exact declared-vs-actual
|
||||
// consistency after Install returns.
|
||||
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type restrictsMismatch struct{}
|
||||
|
||||
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
|
||||
func (restrictsMismatch) Version() string { return "0.1.0" }
|
||||
func (restrictsMismatch) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(restrictsMismatch{}) }
|
||||
`
|
||||
|
||||
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
|
||||
// Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
|
||||
// Capabilities.Restricts=true but Install did not call r.Restrict",
|
||||
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
|
||||
// restricts_mismatch); fix or remove the plugin before running commands"}}
|
||||
func TestInstallRestrictsMismatchPin(t *testing.T) {
|
||||
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
|
||||
}
|
||||
|
||||
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
|
||||
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
|
||||
// plugin.init(), which runs from the blank-import BEFORE main() has a
|
||||
// chance to install any recover-and-envelope guard -- so, unlike every
|
||||
// other case here, this crashes the process outright: no JSON envelope,
|
||||
// non-zero exit, a raw Go panic trace on stderr.
|
||||
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
|
||||
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
|
||||
// crash, because it happens in init(), before main()'s recover guard
|
||||
// exists. Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
|
||||
// match ^[a-z0-9][a-z0-9-]*$
|
||||
//
|
||||
// goroutine 1 [running]:
|
||||
// larkcustomer/plugin.init.0(...)
|
||||
// .../plugin/plugin.go:7
|
||||
// ...
|
||||
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
|
||||
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit == 0 {
|
||||
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) {
|
||||
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "panic:") {
|
||||
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
|
||||
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// auditPlugin registers a single After observer matching every command that
|
||||
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
|
||||
// shipped extension/platform/examples/audit-observer example.
|
||||
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObservePin pins the audit observer's stderr line format. Observed
|
||||
// real output (docs +fetch --doc nonexistent, a real read-risk command that
|
||||
// fails downstream with an API error unrelated to the plugin):
|
||||
//
|
||||
// exit=1
|
||||
// stderr=[audit] docs/+fetch
|
||||
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
|
||||
//
|
||||
// The observer line always leads, on its own line, before whatever the
|
||||
// command itself writes to stderr.
|
||||
func TestObservePin(t *testing.T) {
|
||||
bin := buildFork(t, "audit", auditPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
|
||||
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// auditRestrictPlugin combines an After observer with a Restrict rule in one
|
||||
// plugin, so a denied command's stderr carries both the observer's
|
||||
// side-effect and the denial envelope: the framework's contract is that
|
||||
// After observers fire even for denied commands (see
|
||||
// extension/platform/invocation.go's DeniedByPolicy doc).
|
||||
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit-restrict", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
|
||||
// stderr carries BOTH the observer's audit line AND the denial envelope,
|
||||
// concatenated in a single stream, audit line first. Observed real output
|
||||
// (docs +update --doc-token x --content y, denied write_not_allowed):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=[audit] docs/+update
|
||||
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
|
||||
//
|
||||
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
|
||||
// false; the JSON envelope must be sliced out from the first '{' before
|
||||
// parsing it as JSON.
|
||||
func TestObserveOnDeniedPin(t *testing.T) {
|
||||
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
|
||||
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
|
||||
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
|
||||
}
|
||||
i := strings.Index(res.stderr, "{")
|
||||
if i < 0 {
|
||||
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
envelope := res.stderr[i:]
|
||||
if !gjson.Valid(envelope) {
|
||||
t.Fatalf("sliced envelope not JSON: %s", envelope)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
|
||||
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
|
||||
// (internal/hook/install.go) must isolate the panic so command dispatch
|
||||
// still completes normally.
|
||||
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("observer-panic", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {
|
||||
panic("boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
|
||||
// always panics must not affect the command's own outcome. The assertion is
|
||||
// baseline-relative -- the panicking-observer fork's exit code must equal the
|
||||
// noop-observer baseline fork's for the same `schema` command (a local,
|
||||
// network-free, read-risk command), whatever that shared exit code is.
|
||||
// Observed real output at pin time:
|
||||
//
|
||||
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
|
||||
// baseline: exit=0 stderr=(empty)
|
||||
//
|
||||
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
|
||||
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
|
||||
func TestObserverPanicIsolationPin(t *testing.T) {
|
||||
bin := buildFork(t, "observer-panic", observerPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
|
||||
baselineBin := buildFork(t, "smoke", noopPlugin)
|
||||
baseline := run(t, baselineBin, "schema")
|
||||
|
||||
if res.exit != baseline.exit {
|
||||
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
|
||||
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
|
||||
// instead of calling next.
|
||||
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-abort", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "guard",
|
||||
Reason: "blocked for test",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
|
||||
// returned by a Wrapper is converted by wrapAbortError
|
||||
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
|
||||
// denial -- error.type=="validation", error.subtype=="failed_precondition"
|
||||
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
|
||||
// --doc nonexistent, wrapper aborts unconditionally before calling next):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
|
||||
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
|
||||
// request to satisfy the hook's policy, or remove the plugin"}}
|
||||
//
|
||||
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
|
||||
// regardless of the HookName the plugin set on the AbortError itself
|
||||
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
|
||||
func TestWrapAbortPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the abort hint", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
|
||||
// closure itself, not the returned Handler).
|
||||
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-panic", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
panic("wrap boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
|
||||
// factory does not crash the process. recoverWrap (internal/hook/install.go)
|
||||
// converts the panic into the same validation/failed_precondition shape as
|
||||
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
|
||||
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
|
||||
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
|
||||
// command; report the panic to the plugin author or remove the plugin"}}
|
||||
func TestWrapPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the panic hint", hint)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// readonlyPlugin registers a Restrict rule that only allows read-risk
|
||||
// commands under the docs/** and im/** domains. It mirrors the official
|
||||
// example readonly-policy configuration.
|
||||
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
|
||||
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
|
||||
// error.hint contains the literal "reason_code <X>" substring, and the
|
||||
// process exits 2. reason_code lives only in the hint string, not a
|
||||
// structured field.
|
||||
func TestReadonlyDenial(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
|
||||
// It requires a parent command whose *enumerated children* have mixed
|
||||
// allow/deny outcomes, which needs the full command tree from API metadata.
|
||||
// This L4 harness builds a bare-module fork (embedded stub only), so offline
|
||||
// a parent like "sheets" has no known children and collapses to
|
||||
// domain_not_allowed -- identical to the "leaf out of allow list" case and
|
||||
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
|
||||
// unit tests, which construct a mixed-children tree directly.
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
reasonCode string
|
||||
}{
|
||||
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
|
||||
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadonlyAllows asserts the allow-path: a read command inside an
|
||||
// allowed domain must NOT be denied by the policy gate. It may still fail
|
||||
// downstream (e.g. api/auth error), but that failure must not carry the
|
||||
// denial envelope shape and must not exit 2.
|
||||
func TestReadonlyAllows(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit == 2 {
|
||||
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
|
||||
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// identityPlugin registers a Restrict rule scoped to bot identities only.
|
||||
// im +messages-search declares AuthTypes:["user"] (see
|
||||
// shortcuts/im/im_messages_search.go), so it has no intersection with the
|
||||
// rule's bot-only whitelist regardless of which --as value the caller
|
||||
// passes: platform.Rule.Identities is checked against the command's own
|
||||
// static supported-identities annotation, not the runtime --as flag.
|
||||
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("identity-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bot-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
Identities: []platform.Identity{platform.IdentityBot},
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// denylistPlugin registers a Restrict rule that allows the docs/** domain
|
||||
// but explicitly denies docs/+search (a real read-risk leaf, see
|
||||
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
|
||||
// command is rejected before MaxRisk is even consulted.
|
||||
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("denylist-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "deny-search",
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+search"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
|
||||
// docs-only). A command outside both domains (e.g. the top-level "schema"
|
||||
// command, itself read-risk and already proven to hit domain_not_allowed
|
||||
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
|
||||
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
|
||||
// per-rule denials into the aggregate reason_code "no_matching_rule".
|
||||
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("multi-rule-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "im-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
|
||||
// reason_code across this package -- both policy denials (this file) and
|
||||
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
|
||||
// error.type=="validation", error.subtype=="failed_precondition", and
|
||||
// error.hint containing "reason_code <wantReasonCode>". Both paths render
|
||||
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
|
||||
// reason_code in the hint STRING, not a structured error.detail.reason_code
|
||||
// field (contradicting internal/platform/error.go:34's comment).
|
||||
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
|
||||
t.Helper()
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
|
||||
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
|
||||
// rule rejects a command whose declared AuthTypes don't include "bot".
|
||||
func TestIdentityMismatchDenial(t *testing.T) {
|
||||
bin := buildFork(t, "identity", identityPlugin)
|
||||
res := run(t, bin, "im", "+messages-search", "--as", "user")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "identity_mismatch")
|
||||
}
|
||||
|
||||
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
|
||||
// rejects the command even though it also matches Allow.
|
||||
func TestDenylistDenial(t *testing.T) {
|
||||
bin := buildFork(t, "denylist", denylistPlugin)
|
||||
res := run(t, bin, "docs", "+search")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "command_denylisted")
|
||||
}
|
||||
|
||||
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
|
||||
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
|
||||
// not either rule's own per-rule reason_code.
|
||||
func TestMultiRuleDenial(t *testing.T) {
|
||||
bin := buildFork(t, "multirule", multiRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "no_matching_rule")
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain archives HEAD's committed tree once for the whole package before
|
||||
// any fork build runs. It lives here (not in harness.go) because `go test`
|
||||
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
|
||||
// .go file is silently never invoked.
|
||||
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
panic("locate repo root: " + err.Error())
|
||||
}
|
||||
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
|
||||
if err != nil {
|
||||
panic("mkdtemp: " + err.Error())
|
||||
}
|
||||
cleanTree = filepath.Join(baseDir, "larkcli-clean")
|
||||
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
|
||||
panic("mkdir clean tree: " + err.Error())
|
||||
}
|
||||
if err := gitArchive(root, cleanTree); err != nil {
|
||||
panic("git archive: " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(baseDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// noopPlugin registers a plugin that installs nothing observable, proving
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain links
|
||||
// and the fork boots.
|
||||
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("smoke", "0.0.1").
|
||||
Observer(platform.After, "noop", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
func TestSmokeForkBoots(t *testing.T) {
|
||||
bin := buildFork(t, "smoke", noopPlugin)
|
||||
res := run(t, bin, "--help")
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
|
||||
}
|
||||
if res.stdout == "" {
|
||||
t.Fatalf("--help produced empty stdout")
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
|
||||
// offline and secret-free: a real fork binary (built with -tags authsidecar,
|
||||
// exercising the REAL extension/transport/sidecar interceptor) signs a
|
||||
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
|
||||
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
|
||||
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
|
||||
// forwards to an in-test mock upstream.
|
||||
//
|
||||
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
|
||||
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
|
||||
// middle process. That is infeasible for an OFFLINE test, for three
|
||||
// independent reasons, all verified in source:
|
||||
//
|
||||
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
|
||||
// h.cred.ResolveToken(...), which errors out unless the machine has run
|
||||
// `lark-cli auth login` — there is no way to make it return a token
|
||||
// without live credentials.
|
||||
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
|
||||
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
|
||||
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
|
||||
// and would be rejected with 403 (handler.go step 4).
|
||||
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
|
||||
// "https://" + targetHost, ignoring the client-supplied scheme. It can
|
||||
// never be redirected to an http:// mock.
|
||||
//
|
||||
// server-demo's verify+inject logic is ALREADY covered by
|
||||
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
|
||||
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
|
||||
// same code paths this file would otherwise exercise via a real subprocess.
|
||||
//
|
||||
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
|
||||
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
|
||||
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
|
||||
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
|
||||
// same symbols server-demo itself uses. This is the standard shape for this
|
||||
// kind of test: one real external process (the fork binary, compiled with
|
||||
// the production interceptor code) plus two in-process httptest.Server
|
||||
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
|
||||
// without requiring live credentials, real feishu/lark hosts, or TLS.
|
||||
//
|
||||
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
|
||||
// in this file can authenticate against anything real.
|
||||
package sidecar_e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Synthetic, obviously-fake fixtures. None of these are real secrets.
|
||||
const (
|
||||
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
|
||||
testAppID = "cli_test_app_not_real"
|
||||
injectedToken = "fake-injected-token-not-real"
|
||||
)
|
||||
|
||||
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
|
||||
// steps so the flow is readable at a glance; each step's mechanics live in a
|
||||
// dedicated helper below.
|
||||
func TestSidecarHMACRoundTrip(t *testing.T) {
|
||||
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
|
||||
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
|
||||
upstream := startMockUpstream(t)
|
||||
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
|
||||
|
||||
// One real external process: lark-cli built with -tags authsidecar, run
|
||||
// fully offline against the in-test sidecar.
|
||||
bin := buildAuthsidecarFork(t)
|
||||
runFork(t, bin, sc.URL)
|
||||
|
||||
// Assert the three properties of a correct round trip.
|
||||
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
|
||||
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
|
||||
}
|
||||
|
||||
// --- request capture -------------------------------------------------------
|
||||
|
||||
// capturedRequest snapshots the parts of an *http.Request that matter for
|
||||
// assertions, taken before the request (and its body reader) is consumed or
|
||||
// goes out of scope.
|
||||
type capturedRequest struct {
|
||||
method string
|
||||
path string
|
||||
headers http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// requestSink stores the request a stub server saw, guarded so the httptest
|
||||
// handler goroutine and the test goroutine can hand it over safely.
|
||||
type requestSink struct {
|
||||
mu sync.Mutex
|
||||
req *capturedRequest
|
||||
}
|
||||
|
||||
func (s *requestSink) capture(r *http.Request, body []byte) {
|
||||
snap := capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.RequestURI(),
|
||||
headers: r.Header.Clone(),
|
||||
body: body,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.req = &snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *requestSink) get() *capturedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.req
|
||||
}
|
||||
|
||||
// --- mock upstream (stands in for open.feishu.cn) --------------------------
|
||||
|
||||
type mockUpstream struct {
|
||||
*httptest.Server
|
||||
sink requestSink
|
||||
}
|
||||
|
||||
func startMockUpstream(t *testing.T) *mockUpstream {
|
||||
t.Helper()
|
||||
m := &mockUpstream{}
|
||||
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
m.sink.capture(r, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
|
||||
}))
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
|
||||
|
||||
type inTestSidecar struct {
|
||||
*httptest.Server
|
||||
key []byte
|
||||
upstreamURL string
|
||||
sink requestSink
|
||||
|
||||
mu sync.Mutex // guards verifyRan/verifyErr
|
||||
verifyRan bool
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
|
||||
t.Helper()
|
||||
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
|
||||
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.sink.capture(r, body)
|
||||
|
||||
authHeader, ok := s.verifyProxyRequest(w, r, body)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.forwardWithInjectedToken(w, r, body, authHeader)
|
||||
}
|
||||
|
||||
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
|
||||
// version, body SHA256, target validation, and HMAC signature verification.
|
||||
// It records whether verification ran and its result (for assertions) and
|
||||
// returns the auth header the client committed to. On any failure it writes
|
||||
// the HTTP error and returns ok=false.
|
||||
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
|
||||
// Step 0: protocol version.
|
||||
version := r.Header.Get(sidecar.HeaderProxyVersion)
|
||||
if version != sidecar.ProtocolV1 {
|
||||
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 1-2: timestamp + body SHA256.
|
||||
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
||||
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
||||
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
|
||||
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 3: target host, identity, auth-header (all covered by the sig).
|
||||
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
|
||||
if perr != nil {
|
||||
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
||||
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
||||
|
||||
// Step 4: verify HMAC signature over the canonical request.
|
||||
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: r.URL.RequestURI(),
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}, r.Header.Get(sidecar.HeaderProxySignature))
|
||||
s.mu.Lock()
|
||||
s.verifyRan = true
|
||||
s.verifyErr = err
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
return authHeader, true
|
||||
}
|
||||
|
||||
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
|
||||
// server-demo (which forwards to "https://"+targetHost), this test forwards to
|
||||
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
|
||||
// real upstream or a route to targetHost. It strips any client-supplied auth
|
||||
// headers first (the sidecar is the sole source of auth material), injects the
|
||||
// synthetic token into the committed header, and relays the response back.
|
||||
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
|
||||
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
freq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
freq.Header.Del("Authorization")
|
||||
freq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
freq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
if authHeader == "Authorization" {
|
||||
freq.Header.Set("Authorization", "Bearer "+injectedToken)
|
||||
} else {
|
||||
freq.Header.Set(authHeader, injectedToken)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(freq)
|
||||
if err != nil {
|
||||
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(respBody)
|
||||
}
|
||||
|
||||
// verifyResult reports whether step 4 ran and, if so, its error.
|
||||
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.verifyRan, s.verifyErr
|
||||
}
|
||||
|
||||
// isProxyHeader reports whether name is one of the sidecar wire-protocol
|
||||
// headers that must not be copied through to the forwarded (mock upstream)
|
||||
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
|
||||
func isProxyHeader(name string) bool {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
|
||||
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
|
||||
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
|
||||
// is used, both as HMAC signing input and to record what the fork believed
|
||||
// its real destination was — the actual forward in this test always goes to
|
||||
// the in-test mock, never to this host.
|
||||
func parseTargetHost(target string) (string, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("missing host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("userinfo not allowed")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("query not allowed")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment not allowed")
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
|
||||
// --- fork build + run ------------------------------------------------------
|
||||
|
||||
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
|
||||
// production interceptor) and returns the binary path.
|
||||
func buildAuthsidecarFork(t *testing.T) string {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "forkbin")
|
||||
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
|
||||
build.Dir = repoRoot(t)
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork binary: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
|
||||
// exit status is logged but NOT asserted — this test judges wire behavior
|
||||
// (what reached the sidecar/upstream), not the command's own success.
|
||||
func runFork(t *testing.T, binPath, sidecarURL string) {
|
||||
t.Helper()
|
||||
scURL, err := url.Parse(sidecarURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sidecar URL: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
|
||||
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
|
||||
"LARKSUITE_CLI_APP_ID="+testAppID,
|
||||
"LARKSUITE_CLI_BRAND=feishu",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
|
||||
t.Logf("fork stdout: %s", stdout.String())
|
||||
t.Logf("fork stderr: %s", stderr.String())
|
||||
}
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working
|
||||
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repo root: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// --- assertions ------------------------------------------------------------
|
||||
|
||||
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
|
||||
// the real interceptor ran (all proxy headers present, identity=user), stripped
|
||||
// every real/sentinel auth header before signing, and produced a signature that
|
||||
// verified against the shared key.
|
||||
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
|
||||
t.Helper()
|
||||
got := sc.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
|
||||
}
|
||||
ran, verifyErr := sc.verifyResult()
|
||||
if !ran {
|
||||
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
|
||||
}
|
||||
if verifyErr != nil {
|
||||
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
|
||||
}
|
||||
t.Logf("fork->sidecar headers: %v", got.headers)
|
||||
|
||||
// No real/sentinel auth ever left the fork: the interceptor strips the
|
||||
// sentinel before signing, so this hop must carry no auth header at all.
|
||||
if auth := got.headers.Get("Authorization"); auth != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
|
||||
}
|
||||
|
||||
// Proxy headers must be present (proves the interceptor actually ran).
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if got.headers.Get(h) == "" {
|
||||
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
|
||||
}
|
||||
}
|
||||
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
|
||||
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
|
||||
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
|
||||
// never a sentinel or a real one — proving injection actually happened.
|
||||
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
|
||||
t.Helper()
|
||||
got := up.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
|
||||
}
|
||||
t.Logf("sidecar->mock headers: %v", got.headers)
|
||||
|
||||
wantAuth := "Bearer " + injectedToken
|
||||
gotAuth := got.headers.Get("Authorization")
|
||||
if gotAuth != wantAuth {
|
||||
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
|
||||
}
|
||||
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
|
||||
// proving the only token that ever reached "upstream" was the injected one.
|
||||
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
|
||||
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user