mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
12 Commits
codex/fix-
...
feat/embed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f23badfea | ||
|
|
097bccf060 | ||
|
|
d418e25281 | ||
|
|
e32fdb8c7f | ||
|
|
2aab338374 | ||
|
|
b9d4dce92a | ||
|
|
9ead47e525 | ||
|
|
15d0254b91 | ||
|
|
a5c2a45ecf | ||
|
|
fc630ba8f9 | ||
|
|
70cee047a2 | ||
|
|
0d3ecdb990 |
70
cmd/dispatch_golden_test.go
Normal file
70
cmd/dispatch_golden_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/envelope"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestDispatchErrorGoldenParity asserts the public envelope.DispatchError
|
||||
// triple is byte-identical to what the real root dispatcher
|
||||
// (handleRootError) writes to stderr, with the same exit code, for every
|
||||
// error class (spec G1-G6). Comparison happens at the dispatch boundary:
|
||||
// need_user_authorization hint folding runs before dispatch and is not part
|
||||
// of the public contract, so cases here must not depend on it.
|
||||
func TestDispatchErrorGoldenParity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"G1_typed_validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")},
|
||||
{"G2_typed_extension_fields", &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypePermissionDenied,
|
||||
Code: 99991679,
|
||||
Message: "missing required scopes",
|
||||
Hint: "re-auth with the listed scopes",
|
||||
},
|
||||
MissingScopes: []string{"im:message", "docs:doc"},
|
||||
Identity: "user",
|
||||
}},
|
||||
{"G3_confirmation_required", errs.NewConfirmationRequiredError(
|
||||
"high-risk-write", "drive +delete", "drive +delete requires confirmation")},
|
||||
{"G4_partial_failure", output.PartialFailure(1)},
|
||||
{"G5_cobra_usage", fmt.Errorf(`required flag(s) "values" not set`)},
|
||||
{"G6_leaked_untyped", errors.New("boom")},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
realExit := handleRootError(f, tc.err)
|
||||
env, code, has := envelope.DispatchError(tc.err, string(f.ResolvedIdentity))
|
||||
|
||||
if code != realExit {
|
||||
t.Errorf("exit code: public %d, real dispatcher %d", code, realExit)
|
||||
}
|
||||
if has != (errOut.Len() > 0) {
|
||||
t.Errorf("hasEnvelope=%v but real stderr len=%d", has, errOut.Len())
|
||||
}
|
||||
if !bytes.Equal(env, errOut.Bytes()) {
|
||||
t.Errorf("envelope bytes differ\npublic: %s\nreal: %s", env, errOut.Bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
111
cmd/root.go
111
cmd/root.go
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -229,109 +228,25 @@ func configureFlagCompletions(args []string) {
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, challenge_url, ...) to the top level.
|
||||
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
|
||||
// constructed typed at their origin (internal/auth, internal/core), so the
|
||||
// dispatcher no longer promotes any legacy shape here.
|
||||
// 2. PartialFailure / BareError signals: the result envelope is already on
|
||||
// stdout; honor the exit code and write nothing to stderr.
|
||||
// 3. Residual cobra usage errors (missing required flag, unknown command,
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
// and returns the process exit code. The classification itself (typed
|
||||
// envelope vs. PartialFailure/Bare signal vs. cobra-usage vs. leaked-untyped)
|
||||
// lives in output.DispatchError so the public extension/envelope facade
|
||||
// shares the exact same branches — see its doc comment for the dispatch
|
||||
// order.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
// current command's declared scopes as a Hint. The hint depends on the
|
||||
// Factory, so it stays in the cmd layer — it is applied before dispatch
|
||||
// and is not part of the public DispatchError contract.
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
||||
// (partial-write still returns true) so the exit code we read here is
|
||||
// preserved even if stderr is torn — torn stderr must not downgrade
|
||||
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
env, code, has := output.DispatchError(err, string(f.ResolvedIdentity))
|
||||
if has {
|
||||
// Best-effort write: a torn stderr must not downgrade the typed exit.
|
||||
_, _ = f.IOStreams.ErrOut.Write(env)
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
|
||||
// stdout already carries the result; honor the requested exit code and
|
||||
// write nothing to stderr.
|
||||
var bareErr *output.BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return code
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
|
||||
42
extension/apimeta/apimeta.go
Normal file
42
extension/apimeta/apimeta.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package apimeta lets Go module integrators install embedded API metadata
|
||||
// for this process, equivalent to the meta_data.json that official lark-cli
|
||||
// builds compile in via go:embed.
|
||||
//
|
||||
// Binaries built from the bare Go module embed only an empty metadata stub,
|
||||
// so schema resolution and generated service commands have nothing to work
|
||||
// with offline. Integrators that ship their own metadata (typically embedded
|
||||
// into their binary with their own go:embed directive) call SetEmbedded at
|
||||
// process start to make the CLI treat those bytes as the compiled-in
|
||||
// metadata, with no behavioral difference from an official build.
|
||||
package apimeta
|
||||
|
||||
import "github.com/larksuite/cli/internal/registry"
|
||||
|
||||
// ErrAlreadyLoaded reports that SetEmbedded was called after the embedded
|
||||
// metadata had already been parsed, so the injection was rejected. Use
|
||||
// errors.Is(err, ErrAlreadyLoaded) to detect it.
|
||||
var ErrAlreadyLoaded = registry.ErrMetaAlreadyLoaded
|
||||
|
||||
// SetEmbedded installs data as this process's embedded API metadata.
|
||||
//
|
||||
// It must be called before any registry consumption — cmd.Build, cmd.Execute,
|
||||
// schema resolution, or scope discovery — typically at the top of main() or
|
||||
// from an init() function — early enough provided no other init in the process
|
||||
// has already triggered registry consumption. Calling it after the metadata has
|
||||
// been parsed returns ErrAlreadyLoaded.
|
||||
//
|
||||
// data must parse as lark-cli API metadata and declare at least one service;
|
||||
// otherwise an error is returned and the existing state (the empty stub, or
|
||||
// the compiled-in metadata of an official build) is left unchanged.
|
||||
//
|
||||
// data is copied on success; the caller may reuse or modify the buffer afterwards.
|
||||
//
|
||||
// Calling SetEmbedded multiple times before the first parse is allowed: the last
|
||||
// successful call wins, mirroring ordinary Go process-init trust — whoever
|
||||
// links code into the binary controls its metadata.
|
||||
func SetEmbedded(data []byte) error {
|
||||
return registry.SetEmbeddedMeta(data)
|
||||
}
|
||||
30
extension/apimeta/apimeta_test.go
Normal file
30
extension/apimeta/apimeta_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apimeta_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/apimeta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// A1: 非法数据经门脸返回错误,且不是 ErrAlreadyLoaded
|
||||
func TestSetEmbedded_InvalidDataRejected(t *testing.T) {
|
||||
err := apimeta.SetEmbedded([]byte(`{"broken`))
|
||||
if err == nil {
|
||||
t.Fatalf("SetEmbedded(invalid) = nil, want error")
|
||||
}
|
||||
if errors.Is(err, apimeta.ErrAlreadyLoaded) {
|
||||
t.Fatalf("SetEmbedded(invalid) = ErrAlreadyLoaded, want parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// A2: sentinel 与 internal/registry 同值,errors.Is 语义成立
|
||||
func TestErrAlreadyLoaded_AliasesRegistrySentinel(t *testing.T) {
|
||||
if !errors.Is(apimeta.ErrAlreadyLoaded, registry.ErrMetaAlreadyLoaded) {
|
||||
t.Fatalf("apimeta.ErrAlreadyLoaded is not registry.ErrMetaAlreadyLoaded")
|
||||
}
|
||||
}
|
||||
5
extension/credential/env/env.go
vendored
5
extension/credential/env/env.go
vendored
@@ -89,6 +89,11 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
}
|
||||
}
|
||||
|
||||
if openID := os.Getenv(envvars.CliUserOpenID); openID != "" && hasUAT {
|
||||
acct.OpenID = openID
|
||||
acct.OpenIDVerified = true
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
|
||||
44
extension/credential/env/env_test.go
vendored
44
extension/credential/env/env_test.go
vendored
@@ -280,3 +280,47 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountOpenIDAssertedWithUAT(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-token")
|
||||
t.Setenv(envvars.CliUserOpenID, "ou_injected")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "ou_injected" {
|
||||
t.Errorf("OpenID = %q, want %q", acct.OpenID, "ou_injected")
|
||||
}
|
||||
if !acct.OpenIDVerified {
|
||||
t.Error("OpenIDVerified = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountOpenIDIgnoredWithoutUAT(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-token") // 仅 TAT,无 UAT
|
||||
t.Setenv(envvars.CliUserOpenID, "ou_injected")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "" || acct.OpenIDVerified {
|
||||
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (no UAT)", acct.OpenID, acct.OpenIDVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountNoOpenIDEnvUnchanged(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-token")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "" || acct.OpenIDVerified {
|
||||
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (env not set)", acct.OpenID, acct.OpenIDVerified)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,28 @@ func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
// OpenID is the optional user open_id hint. If a UAT is available, the
|
||||
// user_info API result takes precedence unless OpenIDVerified is set.
|
||||
OpenID string
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
// OpenIDVerified marks OpenID as an identity assertion the provider has
|
||||
// already verified. When true and OpenID is non-empty, the CLI skips the
|
||||
// startup user_info verification call and uses OpenID as-is wherever the
|
||||
// resolved user identity is consumed (identity selection, whoami display,
|
||||
// stored-token lookup). The CLI does NOT re-verify the asserted value:
|
||||
// a mismatched assertion is the responsibility of the integrator that
|
||||
// manages the token supply, and whoami will display the injected,
|
||||
// unverified value on this path. Setting OpenIDVerified with an empty
|
||||
// OpenID is treated as unasserted and falls back to normal verification.
|
||||
//
|
||||
// Appended at the end of the struct so adding it does not shift the
|
||||
// positions of existing fields for unkeyed struct literals.
|
||||
OpenIDVerified bool
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
|
||||
35
extension/envelope/envelope.go
Normal file
35
extension/envelope/envelope.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package envelope exposes lark-cli's error-dispatch decision to embedders.
|
||||
//
|
||||
// Integrators that call cmd.Build and drive Execute themselves must render
|
||||
// errors like the official binary so agents can parse stderr uniformly.
|
||||
// DispatchError is the same function the official root dispatcher consumes,
|
||||
// so error classification, exit codes, and envelope bytes match for every
|
||||
// error the dispatcher receives.
|
||||
//
|
||||
// One narrow exception: the official root dispatcher enriches a
|
||||
// need_user_authorization error with the current command's declared scopes
|
||||
// (via a cmdutil.Factory it holds) before calling DispatchError. That
|
||||
// enrichment depends on command context an embedder does not have, so a
|
||||
// direct DispatchError call on a raw need_user_authorization error produces
|
||||
// an otherwise-identical envelope without the folded-in scope hint. All other
|
||||
// error categories are unaffected.
|
||||
package envelope
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// DispatchError classifies err exactly like lark-cli's own root dispatcher
|
||||
// and returns the stderr envelope bytes (if any) together with the process
|
||||
// exit code. identity is the resolved identity string ("user", "bot", or ""
|
||||
// to omit the field). Typical embedder epilogue:
|
||||
//
|
||||
// env, code, has := envelope.DispatchError(err, "user")
|
||||
// if has {
|
||||
// _, _ = os.Stderr.Write(env)
|
||||
// }
|
||||
// os.Exit(code)
|
||||
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
|
||||
return output.DispatchError(err, identity)
|
||||
}
|
||||
@@ -181,7 +181,13 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if acct.OpenIDVerified && acct.OpenID != "" {
|
||||
// Provider asserted a verified identity (e.g. env provider's
|
||||
// LARKSUITE_CLI_USER_OPEN_ID): skip the startup user_info
|
||||
// verification. UserOpenId is already populated by
|
||||
// convertAccount; the token's validity is still enforced by
|
||||
// the first real API call.
|
||||
} else if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -491,3 +492,99 @@ func TestActiveExtensionProviderName_SkipsNilProvider(t *testing.T) {
|
||||
t.Errorf("got %q, want empty string", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AssertedOpenIDSkipsUserInfo(t *testing.T) {
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "asserted",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu,
|
||||
OpenID: "ou_injected", OpenIDVerified: true,
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("user_info must not be fetched on asserted path")
|
||||
},
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() called %d times, want 0 (verification skipped)", httpClientCalls)
|
||||
}
|
||||
if acct.UserOpenId != "ou_injected" {
|
||||
t.Errorf("UserOpenId = %q, want %q", acct.UserOpenId, "ou_injected")
|
||||
}
|
||||
if acct.UserName != "" {
|
||||
t.Errorf("UserName = %q, want empty on asserted path", acct.UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_VerifiedFlagWithoutOpenIDFallsBack(t *testing.T) {
|
||||
// 非法组合:OpenIDVerified=true 但 OpenID 空 → 视为未断言,照常尝试验证。
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "misconfigured",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenIDVerified: true,
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("fail verification")
|
||||
},
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if httpClientCalls == 0 {
|
||||
t.Fatal("httpClient() not called, want verification attempt (invalid combination must fall back)")
|
||||
}
|
||||
// enrich 失败 → 现有防御逻辑清空未验证身份
|
||||
if acct.UserOpenId != "" {
|
||||
t.Errorf("UserOpenId = %q, want empty after failed verification", acct.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_UnassertedOpenIDStillOverridden(t *testing.T) {
|
||||
// 现有语义保持:OpenID 非空但未断言 → 有 UAT 时 API 结果覆盖。
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open-apis/authen/v1/user_info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","data":{"open_id":"ou_from_api","name":"API User"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// Endpoint injection mirrors tat_fetch_test.go's TestFetchTAT_ContextCanceled:
|
||||
// there is no env-var endpoint override in this repo, so we rewrite the
|
||||
// request host to the test server via a custom RoundTripper instead.
|
||||
hc := &http.Client{Transport: &urlRewriteRT{base: srv.URL}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "hint-only",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenID: "ou_hint",
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return hc, nil },
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "ou_from_api" {
|
||||
t.Errorf("UserOpenId = %q, want %q (API result takes precedence)", acct.UserOpenId, "ou_from_api")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
CliUserOpenID = "LARKSUITE_CLI_USER_OPEN_ID"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
|
||||
91
internal/output/dispatch.go
Normal file
91
internal/output/dispatch.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// DispatchError classifies err exactly like the root command dispatcher and
|
||||
// returns the rendered stderr envelope (if any) together with the process
|
||||
// exit code. It is the single classification path shared by lark-cli's own
|
||||
// root dispatcher and the public extension/envelope facade, so embedders that
|
||||
// drive Execute themselves render errors byte-identically to the official
|
||||
// binary.
|
||||
//
|
||||
// Classification, in order:
|
||||
//
|
||||
// 1. nil → (nil, 0, false).
|
||||
// 2. Typed errs.* carrying a Problem → (envelope, ExitCodeOf(err), true).
|
||||
// If envelope encoding fails the error falls through to branch 4 so
|
||||
// stderr is never blank.
|
||||
// 3. *PartialFailureError / *BareError → (nil, signal code, false): the
|
||||
// result envelope is already on stdout; write nothing to stderr.
|
||||
// 4. Remaining untyped errors: cobra usage text → invalid_argument envelope
|
||||
// with exit 2; anything else leaked past the typed boundary → internal
|
||||
// envelope with exit 5.
|
||||
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
|
||||
if err == nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
typedExit := ExitCodeOf(err)
|
||||
if env, ok := renderTypedEnvelope(err, identity); ok {
|
||||
return env, typedExit, true
|
||||
}
|
||||
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return nil, pfErr.Code, false
|
||||
}
|
||||
var bareErr *BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return nil, bareErr.Code, false
|
||||
}
|
||||
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
env, ok := renderTypedEnvelope(fallback, identity)
|
||||
if !ok {
|
||||
return nil, ExitCodeOf(fallback), false
|
||||
}
|
||||
return env, ExitCodeOf(fallback), true
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
83
internal/output/dispatch_test.go
Normal file
83
internal/output/dispatch_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestDispatchErrorNil(t *testing.T) {
|
||||
env, code, has := DispatchError(nil, "user")
|
||||
if env != nil || code != 0 || has {
|
||||
t.Fatalf("DispatchError(nil) = (%v, %d, %v), want (nil, 0, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorTyped(t *testing.T) {
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")
|
||||
env, code, has := DispatchError(err, "user")
|
||||
if !has || code != ExitCodeOf(err) {
|
||||
t.Fatalf("has=%v code=%d, want true / %d", has, code, ExitCodeOf(err))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if jsonErr := json.Unmarshal(env, &parsed); jsonErr != nil {
|
||||
t.Fatalf("envelope not valid JSON: %v", jsonErr)
|
||||
}
|
||||
if parsed["ok"] != false || parsed["identity"] != "user" {
|
||||
t.Errorf("envelope ok/identity = %v/%v", parsed["ok"], parsed["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorPartialFailure(t *testing.T) {
|
||||
env, code, has := DispatchError(PartialFailure(1), "user")
|
||||
if env != nil || code != 1 || has {
|
||||
t.Fatalf("got (%v, %d, %v), want (nil, 1, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorBare(t *testing.T) {
|
||||
env, code, has := DispatchError(ErrBare(3), "user")
|
||||
if env != nil || code != 3 || has {
|
||||
t.Fatalf("got (%v, %d, %v), want (nil, 3, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorCobraUsage(t *testing.T) {
|
||||
env, code, has := DispatchError(fmt.Errorf(`required flag(s) "values" not set`), "user")
|
||||
if !has || code != 2 {
|
||||
t.Fatalf("has=%v code=%d, want true / 2", has, code)
|
||||
}
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
errObj := parsed["error"].(map[string]any)
|
||||
if errObj["subtype"] != "invalid_argument" {
|
||||
t.Errorf("subtype = %v, want invalid_argument", errObj["subtype"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorLeakedUntyped(t *testing.T) {
|
||||
env, code, has := DispatchError(errors.New("boom"), "bot")
|
||||
if !has || code != 5 {
|
||||
t.Fatalf("has=%v code=%d, want true / 5", has, code)
|
||||
}
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
if parsed["identity"] != "bot" {
|
||||
t.Errorf("identity = %v, want bot", parsed["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorEmptyIdentityOmitted(t *testing.T) {
|
||||
env, _, _ := DispatchError(errs.NewValidationError(errs.SubtypeInvalidArgument, "x"), "")
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
if _, present := parsed["identity"]; present {
|
||||
t.Error("identity field present, want omitted for empty identity")
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,30 @@ func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// renderTypedEnvelope serializes the typed-error envelope for err. It returns
|
||||
// (nil, false) when err carries no Problem or when JSON encoding fails — the
|
||||
// dispatcher then falls through to its signal / usage-error branches.
|
||||
func renderTypedEnvelope(err error, identity string) ([]byte, bool) {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
return nil, false
|
||||
}
|
||||
return buf.Bytes(), true
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
@@ -56,30 +80,11 @@ func PartialFailure(code int) *PartialFailureError {
|
||||
// Returns false only when err carries no Problem (the dispatcher then handles
|
||||
// it via its signal / usage-error branches) or when JSON encoding itself failed.
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
b, ok := renderTypedEnvelope(err, identity)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here; the dispatcher's fall-through
|
||||
// branches still surface the error, so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
// Best-effort write. Partial-write does not downgrade the success status:
|
||||
// the dispatcher has already captured ExitCodeOf(err) before calling us,
|
||||
// and a torn stderr is preferable to falling through to the plain
|
||||
// "Error:" path with exit 1.
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
_, _ = w.Write(b)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -31,6 +34,43 @@ var (
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// ErrMetaAlreadyLoaded is returned by SetEmbeddedMeta when the embedded
|
||||
// metadata has already been parsed; injection must happen before any
|
||||
// registry consumption. extension/apimeta re-exports it as ErrAlreadyLoaded.
|
||||
var ErrMetaAlreadyLoaded = errors.New("embedded api metadata already parsed")
|
||||
|
||||
var (
|
||||
// embeddedInjectMu serializes SetEmbeddedMeta against the first parse so
|
||||
// check-then-write and mark-then-parse never interleave: an injection
|
||||
// either fully lands before the parse or fails with ErrMetaAlreadyLoaded.
|
||||
embeddedInjectMu sync.Mutex
|
||||
embeddedParsed bool // set inside parseEmbedded's Once body
|
||||
)
|
||||
|
||||
// SetEmbeddedMeta validates data and installs it as this process's embedded
|
||||
// API metadata — the same variable go:embed fills in official builds, so every
|
||||
// downstream consumer (schema, command generation, scope discovery, cache
|
||||
// overlay version gating) behaves exactly as an official build would.
|
||||
//
|
||||
// It is the internal engine of extension/apimeta.SetEmbedded; see that
|
||||
// package for the public contract.
|
||||
func SetEmbeddedMeta(data []byte) error {
|
||||
reg, err := meta.Parse(data) // validate before write; meta.Parse(nil/empty) returns a zero Registry with no error
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid api metadata: %w", err)
|
||||
}
|
||||
if len(reg.Services) == 0 {
|
||||
return errors.New("api metadata contains no services")
|
||||
}
|
||||
embeddedInjectMu.Lock()
|
||||
defer embeddedInjectMu.Unlock()
|
||||
if embeddedParsed {
|
||||
return ErrMetaAlreadyLoaded
|
||||
}
|
||||
embeddedMetaJSON = bytes.Clone(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEmbedded decodes the embedded meta_data.json into the typed model exactly
|
||||
// once. It is the single parse of the embedded bytes: both the overlay-free
|
||||
// envelope path (EmbeddedServicesTyped) and the merged command/scope path
|
||||
@@ -38,6 +78,9 @@ var (
|
||||
// twice and no map round-trip is needed downstream.
|
||||
func parseEmbedded() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedInjectMu.Lock()
|
||||
embeddedParsed = true
|
||||
embeddedInjectMu.Unlock()
|
||||
reg, _ := meta.Parse(embeddedMetaJSON)
|
||||
embeddedVersion = reg.Version
|
||||
embeddedServices = reg.Services
|
||||
|
||||
126
internal/registry/loader_inject_test.go
Normal file
126
internal/registry/loader_inject_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
const injectValidMetaJSON = `{"version":"9.9.9","services":[{"name":"testsvc","title":"Test Service","resources":{}}]}`
|
||||
|
||||
// resetInjectState resets package state for injection tests and restores the
|
||||
// original embedded bytes afterwards (same save/restore pattern as
|
||||
// catalog_test.go). resetInit() itself clears embeddedParsed.
|
||||
func resetInjectState(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := embeddedMetaJSON
|
||||
resetInit()
|
||||
embeddedServices = nil
|
||||
embeddedServicesByName = nil
|
||||
t.Cleanup(func() {
|
||||
resetInit()
|
||||
embeddedServices = nil
|
||||
embeddedServicesByName = nil
|
||||
embeddedMetaJSON = orig
|
||||
})
|
||||
}
|
||||
|
||||
// R1: parse 前注入合法 meta → 生效,version 基线更新
|
||||
func TestSetEmbeddedMeta_InjectBeforeParse(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
|
||||
}
|
||||
svcs := EmbeddedServicesTyped()
|
||||
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
|
||||
t.Fatalf("EmbeddedServicesTyped() = %+v, want single service testsvc", svcs)
|
||||
}
|
||||
if embeddedVersion != "9.9.9" { // R7: overlay 门禁的比较基线来源正确
|
||||
t.Fatalf("embeddedVersion = %q, want %q", embeddedVersion, "9.9.9")
|
||||
}
|
||||
}
|
||||
|
||||
// R1b: parse 前多次注入 → 后写覆盖(注入者赢,spec §3.2 末行)
|
||||
func TestSetEmbeddedMeta_LastWriteWinsBeforeParse(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
first := `{"version":"1.0.0","services":[{"name":"firstsvc","resources":{}}]}`
|
||||
if err := SetEmbeddedMeta([]byte(first)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta(first) = %v, want nil", err)
|
||||
}
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta(second) = %v, want nil", err)
|
||||
}
|
||||
svcs := EmbeddedServicesTyped()
|
||||
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
|
||||
t.Fatalf("EmbeddedServicesTyped() = %+v, want last-injected testsvc", svcs)
|
||||
}
|
||||
}
|
||||
|
||||
// R2: 注入后 SchemaCatalog 走 embedded 快路径
|
||||
func TestSetEmbeddedMeta_SchemaCatalogUsesEmbedded(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
|
||||
}
|
||||
cat := SchemaCatalog()
|
||||
if cat.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("SchemaCatalog().Source() = %q, want %q", cat.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := cat.Service("testsvc"); !ok {
|
||||
t.Fatalf("SchemaCatalog() missing injected service testsvc")
|
||||
}
|
||||
}
|
||||
|
||||
// R3: 非法 JSON 拒绝且状态不变
|
||||
func TestSetEmbeddedMeta_InvalidJSONRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
before := embeddedMetaJSON
|
||||
err := SetEmbeddedMeta([]byte(`{"broken`))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid api metadata") {
|
||||
t.Fatalf("SetEmbeddedMeta(invalid) = %v, want invalid api metadata error", err)
|
||||
}
|
||||
if string(embeddedMetaJSON) != string(before) {
|
||||
t.Fatalf("embeddedMetaJSON mutated on rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
// R4: 合法 JSON 但 services 为空 → 拒绝且状态不变
|
||||
func TestSetEmbeddedMeta_EmptyServicesRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
before := embeddedMetaJSON
|
||||
for _, in := range []string{`{}`, `{"version":"1.0.0","services":[]}`} {
|
||||
err := SetEmbeddedMeta([]byte(in))
|
||||
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
|
||||
t.Fatalf("SetEmbeddedMeta(%q) = %v, want no-services error", in, err)
|
||||
}
|
||||
}
|
||||
if string(embeddedMetaJSON) != string(before) {
|
||||
t.Fatalf("embeddedMetaJSON mutated on rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
// R5: 首次 parse 之后注入 → ErrMetaAlreadyLoaded
|
||||
func TestSetEmbeddedMeta_AfterParseRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
_ = EmbeddedServicesTyped() // 触发首次 parse
|
||||
err := SetEmbeddedMeta([]byte(injectValidMetaJSON))
|
||||
if !errors.Is(err, ErrMetaAlreadyLoaded) {
|
||||
t.Fatalf("SetEmbeddedMeta(after parse) = %v, want ErrMetaAlreadyLoaded", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R6: nil / 空字节 → 恒走 services 为空路径(meta.Parse 空输入返回零值无错误)
|
||||
func TestSetEmbeddedMeta_NilAndEmptyRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
for _, in := range [][]byte{nil, {}} {
|
||||
err := SetEmbeddedMeta(in)
|
||||
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
|
||||
t.Fatalf("SetEmbeddedMeta(len=%d) = %v, want no-services error", len(in), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func resetInit() {
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
embeddedParseOnce = sync.Once{}
|
||||
embeddedInjectMu.Lock()
|
||||
embeddedParsed = false
|
||||
embeddedInjectMu.Unlock()
|
||||
servicesTypedOnce = sync.Once{}
|
||||
servicesTyped = nil
|
||||
mergedServices = make(map[string]meta.Service)
|
||||
|
||||
Reference in New Issue
Block a user