Compare commits

...

16 Commits

Author SHA1 Message Date
zhaojunlin.0405
5b235130fb test: use separate stderr buffers in gitArchive to avoid data race
archive.Stderr and extract.Stderr shared one strings.Builder; os/exec
spawns a stderr-copy goroutine per command, so git archive and tar (which
run concurrently) could write the builder simultaneously. strings.Builder
is not concurrency-safe, so go test -race would flag it on any run where
both processes emit stderr. Give each process its own buffer and report
the relevant one on failure. Flagged by CodeRabbit on PR #1840.
2026-07-09 20:39:36 +08:00
zhaojunlin.0405
85ccfb618a test: isolate plugin_e2e run() so all forks are deterministic offline
The previous run() inherited the host environment, so a fork's startup
metadata behavior depended on the developer machine's ~/.lark-cli cache
and live network. Tests passed locally but flaked on CI (e.g.
TestSubsystemCredentialBlock reaching a metadata fetch instead of the
credential block). Move the isolated, offline environment (fresh empty
LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) into run()
itself and fold the per-file runEnv/runIsolated helpers into the shared
runWithEnv, so every test reproduces the bare-module customer state on
any machine, including CI. Tests needing runtime metadata still seed it
explicitly via runWithSeededCatalog.
2026-07-09 20:37:37 +08:00
zhaojunlin.0405
eee38bb96f test: make plugin_e2e forks deterministic offline; cover runtime schema catalog
Two plugin_e2e tests resolved differently on a clean CI runner than on a
developer box because they used run(), which inherits the host's ~/.lark-cli
metadata cache and network. On CI (cold cache, offline) they failed; locally
they passed.

- Replace TestSubsystemTransportAbort with TestRuntimeCatalogResolvesSchema.
  The transport round-trip it targeted is unreachable in a bare-module fork
  offline (credential resolution fails first); the transport path is covered
  end-to-end by the sidecar suite and the credential-block test. The new test
  seeds an obviously-synthetic service into a bare-module fork's on-disk cache
  and asserts `schema` resolves it from the runtime catalog -- the primary
  fix for #1764 (module builds now consult the runtime catalog instead of
  returning "Unknown service"). It complements the existing cold-cache degrade
  test, pinning both branches, and runs fully offline.
- Drop the readonly parent-group sub-case: mixed_children_policy needs a
  metadata-enumerated child tree the bare-module fork does not have, so offline
  it collapses to domain_not_allowed; that reason_code is covered by cmdpolicy
  unit tests.
- Fix errorlint in run() (errors.As), and stream `git archive` into `tar`
  through an explicit pipe instead of a shell in gitArchive.
- Set persist-credentials: false on the two new job checkouts and reuse
  assertReasonCodeEnvelope in TestReadonlyDenial.
2026-07-09 20:18:22 +08:00
zhaojunlin.0405
15eaaaeccc test: reword transport-abort comment to drop endpoint path 2026-07-09 19:08:37 +08:00
zhaojunlin.0405
407c484ef2 test: split sidecar roundtrip into readable named steps
Extract the 230-line TestSidecarHMACRoundTrip into a short top-level flow
(start stubs -> build+run fork -> assert) backed by focused helpers: the
in-test sidecar handler splits into verifyProxyRequest (steps 0-4) and
forwardWithInjectedToken; the two httptest stand-ins become mockUpstream /
inTestSidecar types sharing a requestSink; build/run/assert move into
buildAuthsidecarFork, runFork, and the two assert* helpers. Behavior unchanged.
2026-07-09 11:47:11 +08:00
zhaojunlin.0405
78f8837567 test: dedupe plugin_e2e envelope assert and fix comments
Merge the byte-identical assertInstallEnvelope into a single neutral
assertReasonCodeEnvelope shared by both policy-denial and install-time
reason_code tests. Reword the observer-panic-isolation comment to describe
the baseline-relative assertion instead of a hardcoded exit 0, and the
auditPlugin comment to say it is based on (not mirrors) the shipped example.
2026-07-08 17:43:55 +08:00
zhaojunlin.0405
1052c9cdbb ci: make plugin/sidecar integration jobs observe-only
The two new L4 jobs (plugin-integration, sidecar-integration) still run
on every PR and report status in the results summary, but their failure
no longer blocks the merge gate during the initial soak. Once they prove
stable, add them back to the results FAILED loop to make them required.
2026-07-08 16:32:37 +08:00
zhaojunlin.0405
ecded9975e ci: add sidecar-integration job and wire results gate 2026-07-08 15:40:57 +08:00
zhaojunlin.0405
d60f71054a test: add sidecar HMAC round-trip L4 and Makefile tag target 2026-07-08 15:37:10 +08:00
zhaojunlin.0405
11d1de8c2e ci: add plugin-integration job and wire results gate 2026-07-08 15:01:24 +08:00
zhaojunlin.0405
10e860ffd2 test: cover stub-metadata degrade and offline subsystem forks 2026-07-08 14:51:44 +08:00
zhaojunlin.0405
9e5bf906fd test: cover install-time reason_codes on broken plugin forks 2026-07-08 13:23:55 +08:00
zhaojunlin.0405
9ef612bc4a test: cover identity/denylist/multi-rule denial, observe and wrap in plugin_e2e 2026-07-08 13:04:03 +08:00
zhaojunlin.0405
71c4c1fda3 test: cover restrict denial, allow-path and diagnostics in plugin_e2e 2026-07-08 11:27:59 +08:00
zhaojunlin.0405
c2a1ef66ed test: add plugin_e2e L4 fork-build harness 2026-07-08 11:12:58 +08:00
zhaojunlin.0405
0c25b26d33 fix: repair authsidecar_demo server-demo test compilation 2026-07-08 10:59:29 +08:00
11 changed files with 1946 additions and 6 deletions

View File

@@ -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
@@ -414,7 +442,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 +462,18 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Add them back here to make them required
# once they have proven stable.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -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
@@ -105,6 +105,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.

View File

@@ -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",
feishu := core.Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
lark := core.Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts

View File

@@ -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(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
"LARKSUITE_CLI_META_TTL=1000000",
)
return runWithEnv(t, bin, env, args...)
}
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
// out-of-domain, so any failure the command produces below is the command's
// own behavior against the empty stub catalog, not a policy denial.
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("plain", "0.1.0").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
// The clean tree embeds only the empty meta_data_default.json stub
// (internal/registry/catalog.go's SchemaCatalog falls through to
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
// environment disables the remote overlay fetch and points the cache dir at
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
//
// Observed real output for both `schema` and `schema im.messages.reply`
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
//
// exit=2
// stdout=(empty)
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"No API metadata available",
// "hint":"this binary has no embedded API metadata; run any command with
// network access to the open platform once so metadata can be fetched and
// cached"}}
//
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
// error with an actionable hint, NOT the raw Go panic crash that
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
// exits 2. The two real discriminators against a crash are the absence of a
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
// are asserted below.
func TestDegradeStubMetadataSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
cases := []struct {
name string
args []string
}{
{"schema root", []string{"schema"}},
{"schema with path", []string{"schema", "im.messages.reply"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := run(t, bin, tc.args...)
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 2 {
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
}
})
}
}
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
// falls back to the merged runtime catalog and the lookup succeeds.
//
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
// cold-cache corner (no runtime data -> graceful "No API metadata available");
// this one pins the warm-cache main path (runtime data present -> schema works),
// so a regression that re-embeds the embedded-only lookup fails HERE with
// "Unknown service" rather than silently passing.
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
out := res.stdout + res.stderr
if strings.Contains(out, "Unknown service") {
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
}
if strings.Contains(out, "No API metadata available") {
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
}
if res.exit != 0 {
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(out, "plugine2e") {
t.Errorf("schema output does not mention the seeded service; out=%s", out)
}
}
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
// (and ResolveToken) unconditionally return a *credential.BlockError.
// internal/credential/credential_provider.go's doResolveAccount returns this
// error straight from the provider loop -- before any defaultAcct fallback
// and, transitively, before the LarkClient/HttpClient phases that would issue
// a real network call ever run (see internal/cmdutil/factory_default.go's
// Phase 2 -> Phase 4 ordering).
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/credential"
)
type blockProvider struct{}
func (blockProvider) Name() string { return "block-cred" }
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func init() {
credential.Register(blockProvider{})
}
`
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
// separate `go test -count` invocations (byte-identical both times, unlike
// the transport-abort case -- credential resolution happens once, before any
// endpoint is chosen, so there is no varying destination URL to leak into the
// message):
//
// exit=5
// stdout=(empty)
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
// "message":"blocked by block-cred: blocked for test"}}
func TestSubsystemCredentialBlock(t *testing.T) {
bin := buildFork(t, "credential-block", credentialBlockPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 5 {
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
t.Errorf("error.type=%q want internal", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
t.Errorf("error.subtype=%q want unknown", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
}
}

View File

@@ -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)
}
}

210
tests/plugin_e2e/harness.go Normal file
View File

@@ -0,0 +1,210 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package plugin_e2e exercises the extension/platform plugin contract the way a
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
// then runs that fork as a subprocess and asserts the real stderr/stdout
// envelopes and exit codes. This is L4 coverage — the in-process unit and
// integration tests (extension/..., cmd/...) assert Go error values in the test
// process and structurally cannot observe envelope serialization, exit codes, or
// the blank-import -> init -> Register -> InstallAll assembly chain.
//
// Mechanism (the "customer build", mirrors xcaddy's build mode):
// 1. `git archive HEAD` a clean tree containing only committed files (so the
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
// + plugin package (its init() calls platform.Register).
// 3. `go build` the fork (offline-capable via the warm module cache), then run
// it as a subprocess and assert.
package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// cleanTree is the git-archived, committed-only source tree of the repo under
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
// TestMain must live in a _test.go file to be recognized by `go test`, so the
// entry point sits there while the rest of the harness mechanism lives here.
var cleanTree string
// baseDir holds the archive tree plus every generated customer module.
var baseDir string
// repoRoot resolves the lark-cli module root from the test's working directory
// (which `go test` sets to the package dir, tests/plugin_e2e).
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
// into `tar -x`. Only tracked files are included — gitignored build artifacts
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
// would see them. It wires the two processes with an explicit pipe rather than a
// shell, so dst never reaches a shell command line.
func gitArchive(root, dst string) error {
archive := exec.Command("git", "archive", "HEAD")
archive.Dir = root
extract := exec.Command("tar", "-x", "-C", dst)
pipe, err := archive.StdoutPipe()
if err != nil {
return err
}
extract.Stdin = pipe
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
// per command, so a shared strings.Builder would be written concurrently by
// both (git archive and tar run in parallel) -- a data race, since
// strings.Builder is not concurrency-safe.
var archiveErr, extractErr strings.Builder
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := extract.Start(); err != nil {
return err
}
if err := archive.Run(); err != nil {
_ = extract.Wait()
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
}
if err := extract.Wait(); err != nil {
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
}
return nil
}
// builtForks caches fork binaries by name so identical forks are built once.
var builtForks = map[string]string{}
// buildFork generates a customer module whose plugin package body is pluginSrc,
// builds the fork, and returns the binary path. Forks are cached by name.
func buildFork(t *testing.T, name, pluginSrc string) string {
t.Helper()
if bin, ok := builtForks[name]; ok {
return bin
}
mod := filepath.Join(baseDir, "fork-"+name)
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
t.Fatalf("mkdir customer module: %v", err)
}
// go.mod: reuse cli's require graph, rename the module, replace cli with the
// local archived tree. This avoids `go mod tidy` (no network at test time).
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
if err != nil {
t.Fatalf("read archived go.mod: %v", err)
}
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
// go.sum: transitive dependency hashes are identical to cli's.
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
if err != nil {
t.Fatalf("read archived go.sum: %v", err)
}
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
bin := filepath.Join(mod, "fork-bin")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = mod
// -mod=mod fixes require annotations copied from cli's go.mod; the default
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
}
builtForks[name] = bin
return bin
}
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
)
func main() { os.Exit(cmd.Execute()) }
`
// result is a subprocess run outcome.
type result struct {
stdout string
stderr string
exit int
}
// run executes the fork binary with args in an isolated, offline environment and
// captures stdout/stderr/exit. Each call gets a fresh empty
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
// open platform. That reproduces the bare-module customer state (no embedded
// metadata, cold cache) deterministically on any machine, including CI: without
// it, whether a command's assertion is reached depends on whether a live network
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
// via runWithSeededCatalog.
func run(t *testing.T, bin string, args ...string) result {
t.Helper()
return runWithEnv(t, bin, isolatedEnv(t), args...)
}
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
// base) by runWithSeededCatalog.
func isolatedEnv(t *testing.T) []string {
t.Helper()
return append(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_REMOTE_META=off",
)
}
// runWithEnv runs bin as a subprocess with the given full environment, capturing
// stdout/stderr/exit.
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, args...)
c.Env = env
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
exit := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
}
}
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -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 \"<unknown>\" (plugin_name_panic): Plugin.Name()
// panicked: deliberate name panic",
// "hint":"plugin \"<unknown>\" failed to install (reason_code
// plugin_name_panic); fix or remove the plugin before running commands"}}
func TestInstallPluginNamePanicPin(t *testing.T) {
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
}
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
// recover leaves the pre-set FailClosed default in place -- so this aborts
// unconditionally too, without the plugin ever declaring a real policy.
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type capabilitiesPanic struct{}
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
func (capabilitiesPanic) Version() string { return "0.1.0" }
func (capabilitiesPanic) Capabilities() platform.Capabilities {
panic("deliberate capabilities panic")
}
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(capabilitiesPanic{}) }
`
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
// real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
// Plugin.Capabilities() panicked: deliberate capabilities panic",
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
// capabilities_panic); fix or remove the plugin before running commands"}}
func TestInstallCapabilitiesPanicPin(t *testing.T) {
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "capabilities_panic")
}
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
// Capabilities.Restricts=true (paired with the required FailClosed) but
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
// (internal/platform/staging.go) checks this exact declared-vs-actual
// consistency after Install returns.
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type restrictsMismatch struct{}
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
func (restrictsMismatch) Version() string { return "0.1.0" }
func (restrictsMismatch) Capabilities() platform.Capabilities {
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
}
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(restrictsMismatch{}) }
`
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
// Observed real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
// Capabilities.Restricts=true but Install did not call r.Restrict",
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
// restricts_mismatch); fix or remove the plugin before running commands"}}
func TestInstallRestrictsMismatchPin(t *testing.T) {
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
}
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
// plugin.init(), which runs from the blank-import BEFORE main() has a
// chance to install any recover-and-envelope guard -- so, unlike every
// other case here, this crashes the process outright: no JSON envelope,
// non-zero exit, a raw Go panic trace on stderr.
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
}
`
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
// crash, because it happens in init(), before main()'s recover guard
// exists. Observed real output (schema):
//
// exit=2
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
// match ^[a-z0-9][a-z0-9-]*$
//
// goroutine 1 [running]:
// larkcustomer/plugin.init.0(...)
// .../plugin/plugin.go:7
// ...
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit == 0 {
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if gjson.Valid(res.stderr) {
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
}
}

View File

@@ -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] <path>" to stderr. Based on (a simplified form of) the
// shipped extension/platform/examples/audit-observer example.
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
FailOpen().
MustBuild())
}
`
// TestObservePin pins the audit observer's stderr line format. Observed
// real output (docs +fetch --doc nonexistent, a real read-risk command that
// fails downstream with an API error unrelated to the plugin):
//
// exit=1
// stderr=[audit] docs/+fetch
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
//
// The observer line always leads, on its own line, before whatever the
// command itself writes to stderr.
func TestObservePin(t *testing.T) {
bin := buildFork(t, "audit", auditPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
}
}
// auditRestrictPlugin combines an After observer with a Restrict rule in one
// plugin, so a denied command's stderr carries both the observer's
// side-effect and the denial envelope: the framework's contract is that
// After observers fire even for denied commands (see
// extension/platform/invocation.go's DeniedByPolicy doc).
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit-restrict", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
// stderr carries BOTH the observer's audit line AND the denial envelope,
// concatenated in a single stream, audit line first. Observed real output
// (docs +update --doc-token x --content y, denied write_not_allowed):
//
// exit=2
// stderr=[audit] docs/+update
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
//
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
// false; the JSON envelope must be sliced out from the first '{' before
// parsing it as JSON.
func TestObserveOnDeniedPin(t *testing.T) {
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
}
i := strings.Index(res.stderr, "{")
if i < 0 {
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
}
envelope := res.stderr[i:]
if !gjson.Valid(envelope) {
t.Fatalf("sliced envelope not JSON: %s", envelope)
}
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
}
}
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
// (internal/hook/install.go) must isolate the panic so command dispatch
// still completes normally.
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("observer-panic", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, _ platform.Invocation) {
panic("boom")
}).
FailOpen().
MustBuild())
}
`
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
// always panics must not affect the command's own outcome. The assertion is
// baseline-relative -- the panicking-observer fork's exit code must equal the
// noop-observer baseline fork's for the same `schema` command (a local,
// network-free, read-risk command), whatever that shared exit code is.
// Observed real output at pin time:
//
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
// baseline: exit=0 stderr=(empty)
//
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
func TestObserverPanicIsolationPin(t *testing.T) {
bin := buildFork(t, "observer-panic", observerPanicPlugin)
res := run(t, bin, "schema")
baselineBin := buildFork(t, "smoke", noopPlugin)
baseline := run(t, baselineBin, "schema")
if res.exit != baseline.exit {
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
}
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
}
}
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
// instead of calling next.
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-abort", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return &platform.AbortError{
HookName: "guard",
Reason: "blocked for test",
}
}
}).
FailOpen().
MustBuild())
}
`
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
// returned by a Wrapper is converted by wrapAbortError
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
// denial -- error.type=="validation", error.subtype=="failed_precondition"
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
// --doc nonexistent, wrapper aborts unconditionally before calling next):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
// request to satisfy the hook's policy, or remove the plugin"}}
//
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
// regardless of the HookName the plugin set on the AbortError itself
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
func TestWrapAbortPin(t *testing.T) {
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
t.Errorf("error.hint=%q want to contain the abort hint", hint)
}
}
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
// closure itself, not the returned Handler).
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-panic", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
panic("wrap boom")
}).
FailOpen().
MustBuild())
}
`
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
// factory does not crash the process. recoverWrap (internal/hook/install.go)
// converts the panic into the same validation/failed_precondition shape as
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
// command; report the panic to the plugin author or remove the plugin"}}
func TestWrapPanicPin(t *testing.T) {
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
t.Errorf("error.hint=%q want to contain the panic hint", hint)
}
}

View File

@@ -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 <X>" substring, and the
// process exits 2. reason_code lives only in the hint string, not a
// structured field.
func TestReadonlyDenial(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
// It requires a parent command whose *enumerated children* have mixed
// allow/deny outcomes, which needs the full command tree from API metadata.
// This L4 harness builds a bare-module fork (embedded stub only), so offline
// a parent like "sheets" has no known children and collapses to
// domain_not_allowed -- identical to the "leaf out of allow list" case and
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
// unit tests, which construct a mixed-children tree directly.
cases := []struct {
name string
args []string
reasonCode string
}{
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
})
}
}
// TestReadonlyAllows asserts the allow-path: a read command inside an
// allowed domain must NOT be denied by the policy gate. It may still fail
// downstream (e.g. api/auth error), but that failure must not carry the
// denial envelope shape and must not exit 2.
func TestReadonlyAllows(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit == 2 {
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
}
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
}
}
// identityPlugin registers a Restrict rule scoped to bot identities only.
// im +messages-search declares AuthTypes:["user"] (see
// shortcuts/im/im_messages_search.go), so it has no intersection with the
// rule's bot-only whitelist regardless of which --as value the caller
// passes: platform.Rule.Identities is checked against the command's own
// static supported-identities annotation, not the runtime --as flag.
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("identity-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "bot-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
Identities: []platform.Identity{platform.IdentityBot},
}).
MustBuild())
}
`
// denylistPlugin registers a Restrict rule that allows the docs/** domain
// but explicitly denies docs/+search (a real read-risk leaf, see
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
// command is rejected before MaxRisk is even consulted.
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("denylist-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "deny-search",
Allow: []string{"docs/**"},
Deny: []string{"docs/+search"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
// docs-only). A command outside both domains (e.g. the top-level "schema"
// command, itself read-risk and already proven to hit domain_not_allowed
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
// per-rule denials into the aggregate reason_code "no_matching_rule".
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("multi-rule-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "im-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
Restrict(&platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
// reason_code across this package -- both policy denials (this file) and
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
// error.type=="validation", error.subtype=="failed_precondition", and
// error.hint containing "reason_code <wantReasonCode>". Both paths render
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
// reason_code in the hint STRING, not a structured error.detail.reason_code
// field (contradicting internal/platform/error.go:34's comment).
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
t.Helper()
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
}
}
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
// rule rejects a command whose declared AuthTypes don't include "bot".
func TestIdentityMismatchDenial(t *testing.T) {
bin := buildFork(t, "identity", identityPlugin)
res := run(t, bin, "im", "+messages-search", "--as", "user")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "identity_mismatch")
}
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
// rejects the command even though it also matches Allow.
func TestDenylistDenial(t *testing.T) {
bin := buildFork(t, "denylist", denylistPlugin)
res := run(t, bin, "docs", "+search")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "command_denylisted")
}
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
// not either rule's own per-rule reason_code.
func TestMultiRuleDenial(t *testing.T) {
bin := buildFork(t, "multirule", multiRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "no_matching_rule")
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,466 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
// offline and secret-free: a real fork binary (built with -tags authsidecar,
// exercising the REAL extension/transport/sidecar interceptor) signs a
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
// forwards to an in-test mock upstream.
//
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
// middle process. That is infeasible for an OFFLINE test, for three
// independent reasons, all verified in source:
//
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
// h.cred.ResolveToken(...), which errors out unless the machine has run
// `lark-cli auth login` — there is no way to make it return a token
// without live credentials.
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
// and would be rejected with 403 (handler.go step 4).
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
// "https://" + targetHost, ignoring the client-supplied scheme. It can
// never be redirected to an http:// mock.
//
// server-demo's verify+inject logic is ALREADY covered by
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
// same code paths this file would otherwise exercise via a real subprocess.
//
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
// same symbols server-demo itself uses. This is the standard shape for this
// kind of test: one real external process (the fork binary, compiled with
// the production interceptor code) plus two in-process httptest.Server
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
// without requiring live credentials, real feishu/lark hosts, or TLS.
//
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
// in this file can authenticate against anything real.
package sidecar_e2e
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/sidecar"
)
// Synthetic, obviously-fake fixtures. None of these are real secrets.
const (
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
testAppID = "cli_test_app_not_real"
injectedToken = "fake-injected-token-not-real"
)
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
// steps so the flow is readable at a glance; each step's mechanics live in a
// dedicated helper below.
func TestSidecarHMACRoundTrip(t *testing.T) {
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
upstream := startMockUpstream(t)
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
// One real external process: lark-cli built with -tags authsidecar, run
// fully offline against the in-test sidecar.
bin := buildAuthsidecarFork(t)
runFork(t, bin, sc.URL)
// Assert the three properties of a correct round trip.
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
}
// --- request capture -------------------------------------------------------
// capturedRequest snapshots the parts of an *http.Request that matter for
// assertions, taken before the request (and its body reader) is consumed or
// goes out of scope.
type capturedRequest struct {
method string
path string
headers http.Header
body []byte
}
// requestSink stores the request a stub server saw, guarded so the httptest
// handler goroutine and the test goroutine can hand it over safely.
type requestSink struct {
mu sync.Mutex
req *capturedRequest
}
func (s *requestSink) capture(r *http.Request, body []byte) {
snap := capturedRequest{
method: r.Method,
path: r.URL.RequestURI(),
headers: r.Header.Clone(),
body: body,
}
s.mu.Lock()
s.req = &snap
s.mu.Unlock()
}
func (s *requestSink) get() *capturedRequest {
s.mu.Lock()
defer s.mu.Unlock()
return s.req
}
// --- mock upstream (stands in for open.feishu.cn) --------------------------
type mockUpstream struct {
*httptest.Server
sink requestSink
}
func startMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{}
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.sink.capture(r, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
}))
t.Cleanup(m.Close)
return m
}
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
type inTestSidecar struct {
*httptest.Server
key []byte
upstreamURL string
sink requestSink
mu sync.Mutex // guards verifyRan/verifyErr
verifyRan bool
verifyErr error
}
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
t.Helper()
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
s.sink.capture(r, body)
authHeader, ok := s.verifyProxyRequest(w, r, body)
if !ok {
return
}
s.forwardWithInjectedToken(w, r, body, authHeader)
}
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
// version, body SHA256, target validation, and HMAC signature verification.
// It records whether verification ran and its result (for assertions) and
// returns the auth header the client committed to. On any failure it writes
// the HTTP error and returns ok=false.
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
// Step 0: protocol version.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return "", false
}
// Step 1-2: timestamp + body SHA256.
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return "", false
}
// Step 3: target host, identity, auth-header (all covered by the sig).
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
if perr != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
return "", false
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
// Step 4: verify HMAC signature over the canonical request.
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: r.URL.RequestURI(),
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, r.Header.Get(sidecar.HeaderProxySignature))
s.mu.Lock()
s.verifyRan = true
s.verifyErr = err
s.mu.Unlock()
if err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
return "", false
}
return authHeader, true
}
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
// server-demo (which forwards to "https://"+targetHost), this test forwards to
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
// real upstream or a route to targetHost. It strips any client-supplied auth
// headers first (the sidecar is the sole source of auth material), injects the
// synthetic token into the committed header, and relays the response back.
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
freq.Header.Add(k, v)
}
}
freq.Header.Del("Authorization")
freq.Header.Del(sidecar.HeaderMCPUAT)
freq.Header.Del(sidecar.HeaderMCPTAT)
if authHeader == "Authorization" {
freq.Header.Set("Authorization", "Bearer "+injectedToken)
} else {
freq.Header.Set(authHeader, injectedToken)
}
resp, err := http.DefaultClient.Do(freq)
if err != nil {
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody)
}
// verifyResult reports whether step 4 ran and, if so, its error.
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.verifyRan, s.verifyErr
}
// isProxyHeader reports whether name is one of the sidecar wire-protocol
// headers that must not be copied through to the forwarded (mock upstream)
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
func isProxyHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
// is used, both as HMAC signing input and to record what the fork believed
// its real destination was — the actual forward in this test always goes to
// the in-test mock, never to this host.
func parseTargetHost(target string) (string, error) {
u, err := url.Parse(target)
if err != nil {
return "", fmt.Errorf("parse: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}
// --- fork build + run ------------------------------------------------------
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
// production interceptor) and returns the binary path.
func buildAuthsidecarFork(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "forkbin")
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
build.Dir = repoRoot(t)
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork binary: %v\n%s", err, out)
}
return bin
}
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
// exit status is logged but NOT asserted — this test judges wire behavior
// (what reached the sidecar/upstream), not the command's own success.
func runFork(t *testing.T, binPath, sidecarURL string) {
t.Helper()
scURL, err := url.Parse(sidecarURL)
if err != nil {
t.Fatalf("parse sidecar URL: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
cmd.Env = append(os.Environ(),
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
"LARKSUITE_CLI_APP_ID="+testAppID,
"LARKSUITE_CLI_BRAND=feishu",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
t.Logf("fork stdout: %s", stdout.String())
t.Logf("fork stderr: %s", stderr.String())
}
// repoRoot resolves the lark-cli module root from the test's working
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
func repoRoot(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("resolve repo root: %v", err)
}
return strings.TrimSpace(string(out))
}
// --- assertions ------------------------------------------------------------
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
// the real interceptor ran (all proxy headers present, identity=user), stripped
// every real/sentinel auth header before signing, and produced a signature that
// verified against the shared key.
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
t.Helper()
got := sc.sink.get()
if got == nil {
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
}
ran, verifyErr := sc.verifyResult()
if !ran {
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
}
if verifyErr != nil {
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
}
t.Logf("fork->sidecar headers: %v", got.headers)
// No real/sentinel auth ever left the fork: the interceptor strips the
// sentinel before signing, so this hop must carry no auth header at all.
if auth := got.headers.Get("Authorization"); auth != "" {
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
}
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
}
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
}
// Proxy headers must be present (proves the interceptor actually ran).
for _, h := range []string{
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
sidecar.HeaderProxyAuthHeader,
} {
if got.headers.Get(h) == "" {
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
}
}
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
}
}
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
// never a sentinel or a real one — proving injection actually happened.
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
t.Helper()
got := up.sink.get()
if got == nil {
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
}
t.Logf("sidecar->mock headers: %v", got.headers)
wantAuth := "Bearer " + injectedToken
gotAuth := got.headers.Get("Authorization")
if gotAuth != wantAuth {
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
}
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
// proving the only token that ever reached "upstream" was the injected one.
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
}
}