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.
This commit is contained in:
zhaojunlin.0405
2026-07-09 20:18:22 +08:00
parent 15eaaaeccc
commit eee38bb96f
4 changed files with 156 additions and 122 deletions

View File

@@ -52,6 +52,8 @@ jobs:
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
@@ -65,6 +67,8 @@ jobs:
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

View File

@@ -5,8 +5,11 @@ package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -14,34 +17,23 @@ import (
"github.com/tidwall/gjson"
)
// runIsolated runs bin like run() (harness.go), but with a per-call
// LARKSUITE_CLI_CONFIG_DIR (t.TempDir()) and LARKSUITE_CLI_REMOTE_META=off.
// This is needed ONLY by TestDegradeStubMetadataSchema below: without it, the
// fork inherits this developer machine's real ~/.lark-cli cache (a prior
// `lark-cli` invocation on this box already populated remote_meta.json) and/or
// makes a live network fetch, either of which would populate the runtime
// catalog with real data and hide the #1764 stub-metadata degrade path this
// test exists to pin. harness.go's run() has no env-override parameter and
// must not be modified to add one (out of scope for this task), so this is a
// small local variant of the same subprocess-capture logic.
func runIsolated(t *testing.T, bin string, args ...string) result {
// runEnv runs bin as a subprocess with the given full environment, capturing
// stdout/stderr/exit. It is the shared capture used by the isolated runners
// below; harness.go's run() (which inherits the host env) is left untouched.
func runEnv(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 = 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",
)
c.Env = env
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
exit := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
@@ -50,6 +42,85 @@ func runIsolated(t *testing.T, bin string, args ...string) result {
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
}
// runIsolated runs bin with a per-call empty LARKSUITE_CLI_CONFIG_DIR and
// LARKSUITE_CLI_REMOTE_META=off, so the fork sees NO cached metadata and makes
// no network fetch, regardless of this developer machine's real ~/.lark-cli
// cache. Used to pin the cold-cache degrade path (TestDegradeStubMetadataSchema).
func runIsolated(t *testing.T, bin string, args ...string) result {
t.Helper()
env := 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",
)
return runEnv(t, bin, env, args...)
}
// 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 runEnv(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
@@ -140,75 +211,34 @@ func TestDegradeStubMetadataSchema(t *testing.T) {
}
}
// transportAbortPlugin registers a transport.Provider whose Interceptor
// implements AbortableInterceptor and unconditionally rejects every request
// from PreRoundTripE, before the built-in RoundTripper chain (and therefore
// before any real network I/O) ever runs -- see extension/transport/types.go's
// AbortableInterceptor doc and internal/cmdutil/transport.go's RoundTrip.
const transportAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"errors"
"net/http"
"github.com/larksuite/cli/extension/transport"
)
type abortInterceptor struct{}
func (abortInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) { return nil }
func (abortInterceptor) PreRoundTripE(req *http.Request) (func(*http.Response, error), error) {
return nil, errors.New("aborted for test")
}
type abortProvider struct{}
func (abortProvider) Name() string { return "abort-transport" }
func (abortProvider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
return abortInterceptor{}
}
func init() {
transport.Register(abortProvider{})
}
`
// TestSubsystemTransportAbort pins the transport.AbortableInterceptor offline
// effect: registering a Provider whose PreRoundTripE always errors turns
// every outbound API call into an abort before any network round trip. The
// destination URL embedded in the message varies run-to-run (it depends on
// whether the SDK fetches an OAuth token first or calls the target endpoint
// first), so the assertion is pinned to the stable part only. Observed real
// output for `docs +fetch --doc nonexistent` (a real read-risk network
// command, run twice across separate `go test -count` invocations):
// 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.
//
// run 1: exit=4 stderr={"ok":false,"identity":"user","error":{"type":"network",
// "subtype":"transport","message":"API call failed: Post
// \"https://open.feishu.cn/open-apis/authen/v2/oauth/token\": extension
// \"abort-transport\" aborted round trip: aborted for test"}}
// run 2: exit=4 stderr={... same shape, message targets the docs fetch
// endpoint instead of the OAuth token URL ...}
func TestSubsystemTransportAbort(t *testing.T) {
bin := buildFork(t, "transport-abort", transportAbortPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
// 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)
if res.exit != 4 {
t.Fatalf("exit=%d want 4; 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 !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
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 got := gjson.Get(res.stderr, "error.type").String(); got != "network" {
t.Errorf("error.type=%q want network", got)
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 got := gjson.Get(res.stderr, "error.subtype").String(); got != "transport" {
t.Errorf("error.subtype=%q want transport", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `extension "abort-transport" aborted round trip: aborted for test`) {
t.Errorf("error.message=%q want to contain the abort-transport reason", msg)
if !strings.Contains(out, "plugine2e") {
t.Errorf("schema output does not mention the seeded service; out=%s", out)
}
}

View File

@@ -21,6 +21,8 @@ package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -48,17 +50,36 @@ func repoRoot() (string, error) {
return strings.TrimSpace(string(out)), nil
}
// gitArchive extracts HEAD's committed tree into dst. 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.
// 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 {
c := exec.Command("bash", "-c", "git archive HEAD | tar -x -C "+shellQuote(dst))
c.Dir = root
return runCmd(c)
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
var errBuf strings.Builder
archive.Stderr = &errBuf
extract.Stderr = &errBuf
if err := extract.Start(); err != nil {
return err
}
if err := archive.Run(); err != nil {
_ = extract.Wait()
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
}
if err := extract.Wait(); err != nil {
return fmt.Errorf("tar extract: %w: %s", err, errBuf.String())
}
return nil
}
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// builtForks caches fork binaries by name so identical forks are built once.
var builtForks = map[string]string{}
@@ -144,7 +165,8 @@ func run(t *testing.T, bin string, args ...string) result {
err := c.Run()
exit := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
@@ -159,17 +181,3 @@ func writeFile(t *testing.T, path, content string) {
t.Fatalf("write %s: %v", path, err)
}
}
func runCmd(c *exec.Cmd) error {
if out, err := c.CombinedOutput(); err != nil {
return &cmdError{err: err, out: out}
}
return nil
}
type cmdError struct {
err error
out []byte
}
func (e *cmdError) Error() string { return e.err.Error() + ": " + string(e.out) }

View File

@@ -37,6 +37,14 @@ func init() {
// 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
@@ -44,26 +52,10 @@ func TestReadonlyDenial(t *testing.T) {
}{
{"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"},
{"parent group all children denied", []string{"sheets"}, "mixed_children_policy"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := run(t, bin, tc.args...)
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 "+tc.reasonCode) {
t.Errorf("hint=%q want to contain reason_code %s", hint, tc.reasonCode)
}
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
})
}
}