test(im): lock example identity, run all examples, guard affordance drift

This commit is contained in:
luozhixiong
2026-07-17 13:05:38 +08:00
parent 6288d7255f
commit b61ba28dea
3 changed files with 133 additions and 35 deletions

View File

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

View File

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

View File

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