mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
266 lines
11 KiB
Go
266 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package plugin_e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
|
|
// runtime overlay in internal/registry unconditionally applies it.
|
|
const seededCatalogVersion = "9.9.9"
|
|
|
|
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
|
|
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
|
|
// gives the runtime catalog real data WITHOUT any network, so a test can prove
|
|
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
|
|
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
|
|
const seededCatalogJSON = `{
|
|
"version": "9.9.9",
|
|
"services": [
|
|
{
|
|
"name": "plugine2e",
|
|
"version": "v1",
|
|
"title": "plugin_e2e synthetic service",
|
|
"description": "synthetic fixture for the runtime-catalog test; not a real API",
|
|
"servicePath": "/open-apis/plugine2e/v1",
|
|
"resources": {
|
|
"widgets": {
|
|
"methods": {
|
|
"get": {
|
|
"id": "plugine2e.widgets.get",
|
|
"path": "/open-apis/plugine2e/v1/widgets/:id",
|
|
"httpMethod": "GET",
|
|
"description": "synthetic read method",
|
|
"risk": "read",
|
|
"accessTokens": ["tenant"],
|
|
"parameters": {
|
|
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}`
|
|
|
|
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
|
|
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
|
|
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
|
|
// meta is left ON so the on-disk cache overlay is consulted, but a long
|
|
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
|
|
// bare-module binary that has runtime metadata available from a warm cache.
|
|
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
|
|
t.Helper()
|
|
cfg := t.TempDir()
|
|
cacheDir := filepath.Join(cfg, "cache")
|
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir cache dir: %v", err)
|
|
}
|
|
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
|
|
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
|
|
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
|
|
env := append(baseEnv(),
|
|
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
|
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
|
|
"LARKSUITE_CLI_META_TTL=1000000",
|
|
)
|
|
return runWithEnv(t, bin, env, args...)
|
|
}
|
|
|
|
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
|
|
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
|
|
// out-of-domain, so any failure the command produces below is the command's
|
|
// own behavior against the empty stub catalog, not a policy denial.
|
|
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
)
|
|
|
|
func init() {
|
|
platform.Register(
|
|
platform.NewPlugin("plain", "0.1.0").
|
|
Observer(platform.After, "noop", platform.All(),
|
|
func(_ context.Context, _ platform.Invocation) {}).
|
|
FailOpen().
|
|
MustBuild())
|
|
}
|
|
`
|
|
|
|
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
|
|
// The clean tree embeds only the empty meta_data_default.json stub
|
|
// (internal/registry/catalog.go's SchemaCatalog falls through to
|
|
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
|
|
// environment disables the remote overlay fetch and points the cache dir at
|
|
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
|
|
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
|
|
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
|
|
//
|
|
// Observed real output for both `schema` and `schema im.messages.reply`
|
|
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
|
|
//
|
|
// exit=2
|
|
// stdout=(empty)
|
|
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
|
// "message":"No API metadata available",
|
|
// "hint":"this binary has no embedded API metadata; run any command with
|
|
// network access to the open platform once so metadata can be fetched and
|
|
// cached"}}
|
|
//
|
|
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
|
|
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
|
|
// error with an actionable hint, NOT the raw Go panic crash that
|
|
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
|
|
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
|
|
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
|
|
// exits 2. The two real discriminators against a crash are the absence of a
|
|
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
|
|
// are asserted below.
|
|
func TestDegradeStubMetadataSchema(t *testing.T) {
|
|
bin := buildFork(t, "plain", plainPlugin)
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"schema root", []string{"schema"}},
|
|
{"schema with path", []string{"schema", "im.messages.reply"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res := run(t, bin, tc.args...)
|
|
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
if res.exit != 2 {
|
|
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
if strings.Contains(res.stderr, "panic:") {
|
|
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
|
|
}
|
|
if !gjson.Valid(res.stderr) {
|
|
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
|
|
}
|
|
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
|
t.Errorf("error.type=%q want validation", got)
|
|
}
|
|
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
|
t.Errorf("error.subtype=%q want failed_precondition", got)
|
|
}
|
|
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
|
|
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
|
|
}
|
|
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
|
|
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
|
|
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
|
|
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
|
|
// build read the embedded-only catalog and returned "Unknown service: <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")
|
|
}
|
|
}
|