diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41eaa9fc42..8fbaca2fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,34 @@ 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 @@ -174,7 +202,11 @@ jobs: run: python3 scripts/fetch_meta.py - name: Run tests with coverage run: | - packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') + # tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that + # have dedicated jobs; exclude the whole subtree so none of them runs a + # second time here — and, crucially, so an observe-only suite's failure + # can never block merges through coverage's spot in the results loop. + packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/') go test -race -coverprofile=coverage.txt -covermode=atomic $packages - name: Upload coverage to Codecov if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} @@ -414,7 +446,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] + needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration] runs-on: ubuntu-latest steps: - name: Evaluate results @@ -434,10 +466,19 @@ 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. Graduation to required is tracked in + # https://github.com/larksuite/cli/issues/1894 (criteria: 4 + # consecutive weeks with zero false positives). FAILED=0 for result in \ "${{ needs.fast-gate.result }}" \ diff --git a/Makefile b/Makefile index aad7eccf25..a37b49e3b9 100644 --- a/Makefile +++ b/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 +.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 all: test @@ -64,6 +64,9 @@ examples-build: go build ./extension/platform/examples/audit-observer go build ./extension/platform/examples/readonly-policy +# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork +# binaries (~1 min warm; a cold module cache also downloads via GOPROXY). +# Deliberate: local `make test` exercises the L4 plugin contract by default. integration-test: build go test -v -count=1 ./tests/... @@ -105,6 +108,14 @@ 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. diff --git a/sidecar/server-demo/handler_test.go b/sidecar/server-demo/handler_test.go index 2bc2a20f27..edd2446fef 100644 --- a/sidecar/server-demo/handler_test.go +++ b/sidecar/server-demo/handler_test.go @@ -18,6 +18,7 @@ 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" @@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) { } func TestBuildAllowedHosts(t *testing.T) { - feishu := struct{ Open, Accounts, MCP string }{ - "https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn", - } - lark := struct{ Open, Accounts, MCP string }{ - "https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com", + feishu := core.Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + } + lark := core.Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", } hosts := buildAllowedHosts(feishu, lark) // feishu hosts diff --git a/tests/plugin_e2e/degrade_subsystem_test.go b/tests/plugin_e2e/degrade_subsystem_test.go new file mode 100644 index 0000000000..48a72d5528 --- /dev/null +++ b/tests/plugin_e2e/degrade_subsystem_test.go @@ -0,0 +1,265 @@ +// 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(baseEnv(), + "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: " 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") + } +} diff --git a/tests/plugin_e2e/diagnostics_test.go b/tests/plugin_e2e/diagnostics_test.go new file mode 100644 index 0000000000..a0cf25d579 --- /dev/null +++ b/tests/plugin_e2e/diagnostics_test.go @@ -0,0 +1,39 @@ +// 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) + } +} diff --git a/tests/plugin_e2e/harness.go b/tests/plugin_e2e/harness.go new file mode 100644 index 0000000000..d7f482deb9 --- /dev/null +++ b/tests/plugin_e2e/harness.go @@ -0,0 +1,246 @@ +// 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" + "sync" + "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. +// builtForksMu guards it: no test in this package uses t.Parallel() today, but +// that is an implicit convention a future test could silently break, and an +// unguarded map write would then be a runtime panic. The lock is held across +// the whole build so concurrent callers also dedupe instead of racing to build +// the same fork twice. +var ( + builtForksMu sync.Mutex + 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() + builtForksMu.Lock() + defer builtForksMu.Unlock() + 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...) +} + +// baseEnv is the host environment with every LARKSUITE_CLI_* variable removed. +// Appending overrides to a raw os.Environ() only isolates the variables we +// explicitly set — a developer machine exporting, say, LARKSUITE_CLI_AUTH_PROXY +// or LARKSUITE_CLI_BRAND would leak them into the fork (the transport +// interceptor and credential providers read them via os.Getenv directly), +// breaking the "deterministic on any machine" guarantee. Stripping the whole +// namespace first makes the fork's CLI-facing environment exactly the +// variables the harness sets, everywhere. +func baseEnv() []string { + env := os.Environ() + kept := env[:0] + for _, kv := range env { + if !strings.HasPrefix(kv, "LARKSUITE_CLI_") { + kept = append(kept, kv) + } + } + return kept +} + +// 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(baseEnv(), + "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() + // A fork that hangs is killed by the context and surfaces as a generic + // exit=-1 ExitError; name the timeout explicitly so the failure reads as + // "hung" rather than "crashed". + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("run %v: timed out after 60s; stdout=%s stderr=%s", args, stdout.String(), stderr.String()) + } + 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) + } +} diff --git a/tests/plugin_e2e/install_test.go b/tests/plugin_e2e/install_test.go new file mode 100644 index 0000000000..812e2dbff3 --- /dev/null +++ b/tests/plugin_e2e/install_test.go @@ -0,0 +1,339 @@ +// 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 \"\" (plugin_name_panic): Plugin.Name() +// panicked: deliberate name panic", +// "hint":"plugin \"\" 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) + } +} diff --git a/tests/plugin_e2e/observe_wrap_test.go b/tests/plugin_e2e/observe_wrap_test.go new file mode 100644 index 0000000000..64a998a478 --- /dev/null +++ b/tests/plugin_e2e/observe_wrap_test.go @@ -0,0 +1,298 @@ +// 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] " 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 "." ("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) + } +} diff --git a/tests/plugin_e2e/restrict_test.go b/tests/plugin_e2e/restrict_test.go new file mode 100644 index 0000000000..ff8ad74c9f --- /dev/null +++ b/tests/plugin_e2e/restrict_test.go @@ -0,0 +1,205 @@ +// 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 " 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 ". 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") +} diff --git a/tests/plugin_e2e/smoke_test.go b/tests/plugin_e2e/smoke_test.go new file mode 100644 index 0000000000..01c9c6f64d --- /dev/null +++ b/tests/plugin_e2e/smoke_test.go @@ -0,0 +1,69 @@ +// 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") + } +} diff --git a/tests/sidecar_e2e/roundtrip_test.go b/tests/sidecar_e2e/roundtrip_test.go new file mode 100644 index 0000000000..4c2bcf6149 --- /dev/null +++ b/tests/sidecar_e2e/roundtrip_test.go @@ -0,0 +1,690 @@ +// 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:) 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) +// built on the real protocol package (sidecar.Verify, sidecar.CanonicalRequest, +// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the +// same symbols server-demo itself uses. Against server-demo/handler.go's +// numbered steps, the coverage accounting is: +// +// - steps 0-3 (protocol version, timestamp presence, body SHA256, HMAC +// verification): MIRRORED in the in-test handler. Note server-demo's +// step 1 checks timestamp presence only — no freshness/skew window +// exists there either; the timestamp's integrity is covered by the HMAC. +// - steps 4/5/5.5 (target-host / identity / auth-header allowlists): NOT +// enforced in the handler (a mock's 127.0.0.1 host can never be in a +// real allowlist); replaced by post-hoc test assertions that the docs +// request named the real Feishu host, identity=user, and the committed +// auth header was present. +// - step 6 (resolve real token): replaced by a synthetic injected token — +// the point of the offline design. +// - steps 7-10 (build forward request, inject, forward, relay response): +// mirrored in shape, except the forward goes to the in-test mock's URL +// instead of "https://"+targetHost (deliberate, documented on +// forwardWithInjectedToken). +// - step 11 (audit log): not covered; irrelevant to the wire contract. +// +// 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" + "encoding/json" + "errors" + "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" + + // testDocToken is the --doc argument runFork passes; the docs +fetch call + // becomes POST /open-apis/docs_ai/v1/documents//fetch. Sharing + // it keeps the request marker below in sync with the command invocation. + testDocToken = "nonexistent" + + // docsReqPath is the exact path of the TARGET docs +fetch request among + // every request the fork routes through the proxy. `docs +fetch --as user` + // resolves a sentinel UAT, and the credential layer then verifies it with + // a mandatory /open-apis/authen/v1/user_info probe (see + // internal/credential/credential_provider.go enrichUserInfo) — so a second + // request also flows through the sidecar. Asserting on whichever arrived + // last would let that identity probe masquerade as the docs request; we + // select the docs call by its full path (exact match, not a substring — a + // wrong API prefix or version must not slip through). + docsReqPath = "/open-apis/docs_ai/v1/documents/" + testDocToken + "/fetch" + + // wantProxyTargetHost is the real Feishu open-platform host the interceptor + // must name as the proxy target for BRAND=feishu. The request is never + // actually forwarded there (the in-test sidecar redirects to the mock); the + // header only records where the fork BELIEVED it was going, and it is HMAC + // signing input, so it must be exactly the real host. + wantProxyTargetHost = "open.feishu.cn" +) + +// 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) + res := runFork(t, bin, sc.URL) + + // Diagnostic dump (shown only on failure or -v): the full request set, so a + // failure makes plain which requests flowed and which one the assertions + // targeted, instead of guessing about the last-arriving request. + for _, s := range sc.seenAll() { + t.Logf("sidecar saw: %s %s target=%q identity=%q verifyRan=%v verifyErr=%v", + s.req.method, s.req.path, s.req.headers.Get(sidecar.HeaderProxyTarget), + s.req.headers.Get(sidecar.HeaderProxyIdentity), s.verifyRan, s.verifyErr) + } + for _, r := range upstream.sink.all() { + t.Logf("upstream saw: %s %s auth=%q", r.method, r.path, r.headers.Get("Authorization")) + } + t.Logf("fork exit=%d\nstdout=%s\nstderr=%s", res.exit, res.stdout, res.stderr) + + // Assert the fork's command itself succeeded end-to-end, not just that some + // bytes reached the sidecar. + assertForkSucceeded(t, res) + + // Assert the three properties of a correct round trip, scoped to the DOCS + // request (not an auxiliary identity probe). + 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 EVERY request a stub server saw, in arrival order, +// guarded so the httptest handler goroutine and the test goroutine can hand +// them over safely. Capturing all requests (not just the last) is what closes +// the false-green window: the fork may route more than one request through the +// proxy, and the target docs request is not guaranteed to be the last. +type requestSink struct { + mu sync.Mutex + reqs []*capturedRequest +} + +func (s *requestSink) capture(r *http.Request, body []byte) *capturedRequest { + snap := &capturedRequest{ + method: r.Method, + path: r.URL.RequestURI(), + headers: r.Header.Clone(), + body: body, + } + s.mu.Lock() + s.reqs = append(s.reqs, snap) + s.mu.Unlock() + return snap +} + +func (s *requestSink) all() []*capturedRequest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]*capturedRequest(nil), s.reqs...) +} + +// find returns the first captured request whose path equals path, or nil. +func (s *requestSink) find(path string) *capturedRequest { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.reqs { + if r.path == path { + return r + } + } + return nil +} + +// --- 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) + // Respond per path so each forwarded request parses as success: the + // identity probe needs authen/v1/user_info's {data:{open_id,name}} to + // resolve cleanly; the docs +fetch is satisfied by the generic code:0 + // envelope. A single canned body would make the identity probe error. + _, _ = w.Write(mockResponseFor(r.URL.Path)) + })) + t.Cleanup(m.Close) + return m +} + +// mockResponseFor returns a minimal success body matching the API the given +// path addresses. Unknown paths get a generic code:0 envelope. +func mockResponseFor(path string) []byte { + if strings.Contains(path, "/authen/v1/user_info") { + return []byte(`{"code":0,"msg":"success","data":{"open_id":"ou_mock","name":"mock user"}}`) + } + return []byte(`{"code":0,"msg":"success","data":{}}`) +} + +// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) -------- + +// sidecarSeen is one request the in-test sidecar received, together with the +// per-request verification outcome. Tracking this per request (not as a single +// last-write-wins field) lets assertions check the verification that belongs +// to the DOCS request specifically. +type sidecarSeen struct { + req *capturedRequest + verifyRan bool + verifyErr error +} + +type inTestSidecar struct { + *httptest.Server + key []byte + upstreamURL string + + mu sync.Mutex // guards seen + seen []sidecarSeen +} + +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-3) -> inject+forward. +func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + snap := &capturedRequest{ + method: r.Method, + path: r.URL.RequestURI(), + headers: r.Header.Clone(), + body: body, + } + + authHeader, verifyRan, verifyErr, ok := s.verifyProxyRequest(w, r, body) + s.mu.Lock() + s.seen = append(s.seen, sidecarSeen{req: snap, verifyRan: verifyRan, verifyErr: verifyErr}) + s.mu.Unlock() + if !ok { + return + } + s.forwardWithInjectedToken(w, r, body, authHeader) +} + +// verifyProxyRequest mirrors server-demo/handler.go steps 0-3 (protocol +// version, timestamp presence, body SHA256, HMAC signature verification — +// including the target parse and identity/auth-header reads that feed the +// canonical signing string). The allowlist steps 4/5/5.5 are intentionally +// absent; the package comment's coverage accounting explains what replaces +// them. It returns the auth header the client committed to, whether HMAC +// verification (step 3) actually ran, and its result. On any earlier failure +// it writes the HTTP error and returns ok=false with verifyRan=false. +func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, verifyRan bool, verifyErr error, 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, nil, false + } + + // Step 1: timestamp presence (matching server-demo, which enforces + // presence only — the value's integrity is covered by the HMAC below; + // an empty-but-signed timestamp would otherwise verify fine). + ts := r.Header.Get(sidecar.HeaderProxyTimestamp) + if ts == "" { + http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest) + return "", false, nil, false + } + + // Step 2: body SHA256. + claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256) + if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) { + http.Error(w, "body SHA256 mismatch", http.StatusBadRequest) + return "", false, nil, false + } + + // Step 3 inputs: 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) + // verifyRan=false: HMAC verification never ran; surface the parse error + // so the diagnostic dump shows why this request was rejected early. + return "", false, perr, false + } + identity := r.Header.Get(sidecar.HeaderProxyIdentity) + authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader) + + // Step 3: 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)) + if err != nil { + http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized) + return "", true, err, false + } + return authHeader, true, nil, 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) +} + +// seenAll returns a copy of every request the sidecar received, in order. +func (s *inTestSidecar) seenAll() []sidecarSeen { + s.mu.Lock() + defer s.mu.Unlock() + return append([]sidecarSeen(nil), s.seen...) +} + +// findSeen returns the first received request whose path equals path, or nil. +func (s *inTestSidecar) findSeen(path string) *sidecarSeen { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.seen { + if s.seen[i].req.path == path { + return &s.seen[i] + } + } + return nil +} + +// 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://" 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 +} + +// forkResult is the fork subprocess outcome. +type forkResult struct { + exit int + stdout string + stderr string +} + +// runFork runs the fork against the in-test sidecar, fully offline, and returns +// its exit code and captured output. LARKSUITE_CLI_REMOTE_META=off is essential: +// without it the fork's startup metadata refresh hits the real +// open.feishu.cn/api/tools/open/api_definition (internal/registry/remote.go), +// which both breaks the "offline, secret-free" contract and makes the run +// depend on live network. With it set, the command still completes and the +// docs request still flows through the sidecar, but nothing leaves the machine. +func runFork(t *testing.T, binPath, sidecarURL string) forkResult { + 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", testDocToken, "--as", "user") + // Strip the host's LARKSUITE_CLI_* namespace before appending overrides: a + // developer machine exporting, say, LARKSUITE_CLI_DEFAULT_AS or + // LARKSUITE_CLI_STRICT_MODE would otherwise leak into the fork (the sidecar + // credential provider reads them via os.Getenv), so the fork's CLI-facing + // environment is exactly the variables set below, on any machine. + env := os.Environ() + base := env[:0] + for _, kv := range env { + if !strings.HasPrefix(kv, "LARKSUITE_CLI_") { + base = append(base, kv) + } + } + cmd.Env = append(base, + "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_REMOTE_META=off", + "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() + exit := 0 + if runErr != nil { + var ee *exec.ExitError + if errors.As(runErr, &ee) { + exit = ee.ExitCode() + } else { + t.Fatalf("run fork: %v", runErr) + } + } + return forkResult{exit: exit, stdout: stdout.String(), stderr: 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 ------------------------------------------------------------ + +// assertForkSucceeded checks the fork command itself completed the round trip: +// exit 0 and an ok:true JSON envelope on stdout. This is what makes the docs +// request a genuine success path, not merely bytes that happened to flow. +func assertForkSucceeded(t *testing.T, res forkResult) { + t.Helper() + if res.exit != 0 { + t.Fatalf("fork exit=%d want 0; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + } + // Parse rather than substring-match: the CLI pretty-prints stdout, so the + // envelope reads "ok": true (with a space), and the field's truth — not its + // serialized spelling — is what proves the round trip succeeded. + var env struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal([]byte(res.stdout), &env); err != nil { + t.Fatalf("fork stdout is not a JSON envelope: %v; stdout=%s stderr=%s", err, res.stdout, res.stderr) + } + if !env.OK { + t.Fatalf("fork stdout ok != true (round trip did not succeed); stdout=%s stderr=%s", res.stdout, res.stderr) + } +} + +// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c) for +// the DOCS request specifically: the real interceptor ran (all proxy headers +// present, identity=user, method+path+target as expected), 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() + seen := sc.findSeen(docsReqPath) + if seen == nil { + t.Fatalf("sidecar never received the docs request (path %q) — interceptor did not route it to AUTH_PROXY; saw %v", + docsReqPath, sidecarPaths(sc.seenAll())) + } + got := seen.req + if !seen.verifyRan { + t.Fatal("sidecar received the docs request but never reached HMAC verification (rejected earlier — see handler headers)") + } + if seen.verifyErr != nil { + t.Fatalf("HMAC verification failed on the fork's own signed docs request: %v", seen.verifyErr) + } + t.Logf("fork->sidecar docs headers: %v", got.headers) + + // Target/method/path: prove we asserted on the real docs call to the real + // Feishu open platform, not an auxiliary request. + if got.method != http.MethodPost { + t.Errorf("docs request method = %q, want POST", got.method) + } + if targetHost, err := parseTargetHost(got.headers.Get(sidecar.HeaderProxyTarget)); err != nil { + t.Errorf("docs request %s invalid: %v", sidecar.HeaderProxyTarget, err) + } else if targetHost != wantProxyTargetHost { + t.Errorf("docs request proxy target host = %q, want %q", targetHost, wantProxyTargetHost) + } + + // 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) for the DOCS request: the mock saw exactly the sidecar-injected +// synthetic token, never a sentinel or a real one — proving injection happened. +func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) { + t.Helper() + got := up.sink.find(docsReqPath) + if got == nil { + t.Fatalf("mock upstream never received the forwarded docs request (path %q) — sidecar did not forward it after verification; saw %v", + docsReqPath, requestPaths(up.sink.all())) + } + t.Logf("sidecar->mock docs 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) + } + // The sidecar wire-protocol headers are between fork and sidecar only — + // the forward must strip every one of them. Token injection alone passing + // would still be a leak if signatures/timestamps/digests reached upstream. + for _, h := range []string{ + sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity, + sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256, + sidecar.HeaderProxyAuthHeader, + } { + if v := got.headers.Get(h); v != "" { + t.Errorf("proxy protocol header %s leaked to upstream (want stripped): %q", h, v) + } + } +} + +// sidecarPaths / requestPaths render captured paths for failure messages. +func sidecarPaths(seen []sidecarSeen) []string { + paths := make([]string, len(seen)) + for i, s := range seen { + paths[i] = s.req.method + " " + s.req.path + } + return paths +} + +func requestPaths(reqs []*capturedRequest) []string { + paths := make([]string, len(reqs)) + for i, r := range reqs { + paths[i] = r.method + " " + r.path + } + return paths +}