mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
test(im): lock example identity, run all examples, guard affordance drift
This commit is contained in:
@@ -6,6 +6,7 @@ package affordance
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -55,9 +56,28 @@ func TestForIMRealFile(t *testing.T) {
|
||||
if len(a.AvoidWhen) == 0 {
|
||||
t.Errorf("%s: missing Avoid when section", m)
|
||||
}
|
||||
if len(a.Examples) == 0 || a.Examples[0].Command == "" {
|
||||
t.Errorf("%s: missing fenced example command", m)
|
||||
continue
|
||||
}
|
||||
// Each example must invoke the section's own command, so a heading
|
||||
// can't silently drift apart from the command its examples show.
|
||||
// Normalize the example's command words (before the first flag) the
|
||||
// same way headings become keys: spaces join with dots.
|
||||
words := strings.Fields(strings.TrimPrefix(a.Examples[0].Command, "lark-cli im "))
|
||||
var cmdWords []string
|
||||
for _, w := range words {
|
||||
if strings.HasPrefix(w, "-") {
|
||||
break
|
||||
}
|
||||
cmdWords = append(cmdWords, w)
|
||||
}
|
||||
if got := strings.Join(cmdWords, "."); got != m {
|
||||
t.Errorf("%s: first example %q invokes %q, want the section's own command", m, a.Examples[0].Command, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Showcase depth: messages forward (spec §3.3, mirrors the tech-plan diff).
|
||||
// Showcase depth: messages forward (the deepest overlay section).
|
||||
raw, ok := For("im", "messages.forward")
|
||||
if !ok {
|
||||
t.Fatal("messages.forward overlay missing")
|
||||
|
||||
@@ -102,11 +102,17 @@ func TestIMTipsFirstExampleCoversRequired(t *testing.T) {
|
||||
if len(examples) == 0 {
|
||||
continue // reported by TestIMTipsExamplesPresent
|
||||
}
|
||||
// Compare whole flag tokens, not substrings: a required --user must
|
||||
// not be satisfied by an example that only carries --user-id.
|
||||
flagTokens := map[string]bool{}
|
||||
for _, tok := range exampleFlagTokenRe.FindAllString(examples[0], -1) {
|
||||
flagTokens[tok] = true
|
||||
}
|
||||
for _, f := range sc.Flags {
|
||||
if !f.Required {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(examples[0], "--"+f.Name) {
|
||||
if !flagTokens["--"+f.Name] {
|
||||
t.Errorf("%s: first example must cover required flag --%s\nexample: %s",
|
||||
cmd, f.Name, examples[0])
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -32,15 +36,16 @@ var tipsPlaceholderValues = map[string]string{
|
||||
"<chat_id2>": "oc_e2etest000000000000000002",
|
||||
}
|
||||
|
||||
// firstExampleArgs extracts the first "Example:" tip of the shortcut, replaces
|
||||
// placeholders, and returns the argv after "lark-cli".
|
||||
func firstExampleArgs(t *testing.T, command string) []string {
|
||||
// allExampleArgs extracts every "Example:" tip of the shortcut, replaces
|
||||
// placeholders, and returns one argv (after "lark-cli") per example.
|
||||
func allExampleArgs(t *testing.T, command string) [][]string {
|
||||
t.Helper()
|
||||
for _, sc := range imshortcuts.Shortcuts() {
|
||||
if sc.Command != command {
|
||||
continue
|
||||
}
|
||||
prefix := "Example: lark-cli "
|
||||
var all [][]string
|
||||
for _, tip := range sc.Tips {
|
||||
if !strings.HasPrefix(tip, prefix) {
|
||||
continue
|
||||
@@ -49,14 +54,34 @@ func firstExampleArgs(t *testing.T, command string) []string {
|
||||
for ph, v := range tipsPlaceholderValues {
|
||||
line = strings.ReplaceAll(line, ph, v)
|
||||
}
|
||||
return splitExampleArgs(t, line)
|
||||
all = append(all, splitExampleArgs(t, line))
|
||||
}
|
||||
t.Fatalf("%s has no Example tip", command)
|
||||
if len(all) == 0 {
|
||||
t.Fatalf("%s has no Example tip", command)
|
||||
}
|
||||
return all
|
||||
}
|
||||
t.Fatalf("shortcut %s not found", command)
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstExampleArgs extracts the first "Example:" tip of the shortcut.
|
||||
func firstExampleArgs(t *testing.T, command string) []string {
|
||||
t.Helper()
|
||||
return allExampleArgs(t, command)[0]
|
||||
}
|
||||
|
||||
// hasAsFlag reports whether the example already carries an explicit --as,
|
||||
// in which case the test must run it verbatim instead of injecting one.
|
||||
func hasAsFlag(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "--as" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitExampleArgs splits a shell-like example line on spaces, honoring
|
||||
// double-quoted segments (the only quoting style used in Tips examples).
|
||||
func splitExampleArgs(t *testing.T, line string) []string {
|
||||
@@ -95,12 +120,13 @@ func runFirstExampleDryRun(t *testing.T, command string, wantAPIPath string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := append(firstExampleArgs(t, command), "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: "bot",
|
||||
WorkDir: t.TempDir(),
|
||||
})
|
||||
exampleArgs := firstExampleArgs(t, command)
|
||||
args := append(exampleArgs, "--dry-run")
|
||||
req := clie2e.Request{Args: args, WorkDir: t.TempDir()}
|
||||
if !hasAsFlag(exampleArgs) {
|
||||
req.DefaultAs = "bot"
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, req)
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, wantAPIPath,
|
||||
@@ -154,32 +180,78 @@ func defaultAsForCommand(t *testing.T, command string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestIMTipsFirstExampleDryRunAll extends the executability lock from the 3
|
||||
// TestIMTipsAllExamplesDryRun extends the executability lock from the 3
|
||||
// path-assertion tests above (messages-send, chat-messages-list,
|
||||
// resources-download) to every one of the 18 shortcuts carrying a locked
|
||||
// Example tip: the first example, with placeholders substituted and
|
||||
// --dry-run appended, must exit 0. This only asserts exit code, not the API
|
||||
// path — the 3 tests above keep that stronger assertion for their targets.
|
||||
func TestIMTipsFirstExampleDryRunAll(t *testing.T) {
|
||||
// resources-download) to every "Example:" tip of all 18 shortcuts: each
|
||||
// example, with placeholders substituted and --dry-run appended, must exit 0.
|
||||
// This only asserts exit code, not the API path — the 3 tests above keep
|
||||
// that stronger assertion for their targets. Examples that already carry an
|
||||
// explicit --as run verbatim; only --as-less examples get an identity
|
||||
// injected (matching each shortcut's AuthTypes).
|
||||
func TestIMTipsAllExamplesDryRun(t *testing.T) {
|
||||
for _, cmd := range tipsExampleAllTargets {
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "im_tips_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_tips_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
for i, exampleArgs := range allExampleArgs(t, cmd) {
|
||||
t.Run(fmt.Sprintf("%s/example_%d", cmd, i+1), func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "im_tips_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_tips_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
as := defaultAsForCommand(t, cmd)
|
||||
args := append(firstExampleArgs(t, cmd), "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: as,
|
||||
WorkDir: t.TempDir(),
|
||||
req := clie2e.Request{
|
||||
Args: append(append([]string{}, exampleArgs...), "--dry-run"),
|
||||
WorkDir: t.TempDir(),
|
||||
}
|
||||
if !hasAsFlag(exampleArgs) {
|
||||
req.DefaultAs = defaultAsForCommand(t, cmd)
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, req)
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIMTipsSendReplyIdentityLock guards the governance rule that send/reply
|
||||
// examples must pin `--as bot` explicitly: under a config whose defaultAs is
|
||||
// "user" (the adversarial case Codex review #4 exposed — a developer machine
|
||||
// with a user login), running each send/reply example VERBATIM must still
|
||||
// resolve to bot identity. If someone drops --as bot from an example, the
|
||||
// bare example resolves to user under this config and the assertion fails.
|
||||
func TestIMTipsSendReplyIdentityLock(t *testing.T) {
|
||||
for _, cmd := range []string{"+messages-send", "+messages-reply"} {
|
||||
for i, exampleArgs := range allExampleArgs(t, cmd) {
|
||||
t.Run(fmt.Sprintf("%s/example_%d", cmd, i+1), func(t *testing.T) {
|
||||
require.True(t, hasAsFlag(exampleArgs),
|
||||
"send/reply examples must carry an explicit --as bot")
|
||||
|
||||
cfgDir := t.TempDir()
|
||||
cfg := `{"currentApp":"im_tips_identity_lock","apps":[{"appId":"im_tips_identity_lock","appSecret":"im_tips_dryrun_secret","brand":"feishu","defaultAs":"user","users":[]}]}`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(cfg), 0o600))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: append(append([]string{}, exampleArgs...), "--dry-run", "--json"),
|
||||
WorkDir: t.TempDir(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, result.RunErr, "binary: %s args: %v", result.BinaryPath, result.Args)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
var envelope struct {
|
||||
Identity string `json:"identity"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &envelope),
|
||||
"dry-run --json stdout should be a JSON envelope")
|
||||
require.Equal(t, "bot", envelope.Identity,
|
||||
"example run verbatim under a user-default config must still send as bot")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user