mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 11:04:26 +08:00
Compare commits
16 Commits
feat/douba
...
ci/plugin-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b235130fb | ||
|
|
85ccfb618a | ||
|
|
eee38bb96f | ||
|
|
15eaaaeccc | ||
|
|
407c484ef2 | ||
|
|
78f8837567 | ||
|
|
1052c9cdbb | ||
|
|
ecded9975e | ||
|
|
d60f71054a | ||
|
|
11d1de8c2e | ||
|
|
10e860ffd2 | ||
|
|
9e5bf906fd | ||
|
|
9ef612bc4a | ||
|
|
71c4c1fda3 | ||
|
|
c2a1ef66ed | ||
|
|
0c25b26d33 |
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -47,6 +47,34 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
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
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
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
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -414,7 +442,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -434,10 +462,18 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Add them back here to make them required
|
||||
# once they have proven stable.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
10
Makefile
10
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -105,6 +105,14 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -30,7 +29,7 @@ var AppsCreate = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack", "modern_html"}},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -72,8 +71,5 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent := os.Getenv("LARKSUITE_CLI_AGENT"); agent != "" {
|
||||
body["source_agent"] = agent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -273,92 +273,3 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT", "doubao")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["source_agent"] != "doubao" {
|
||||
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,12 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -122,29 +119,15 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
AppID: strings.TrimSpace(rctx.Str("app-id")),
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out["app_id"] = spec.AppID
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "app_id: %s\n", spec.AppID)
|
||||
if url, ok := out["url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
if tosPath, ok := out["tos_path"].(string); ok && tosPath != "" {
|
||||
fmt.Fprintf(w, "tos_path: %s\n", tosPath)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -273,91 +256,3 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, appsValidationParamError("--path",
|
||||
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
|
||||
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
|
||||
}
|
||||
|
||||
tarball, err := buildHTMLPublishTarball(rctx.FileIO(), candidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
|
||||
// Step 1: call pre_release to get TOS upload URL and tos_path.
|
||||
preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(spec.AppID))
|
||||
preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kvs, _ := preData["kvs"].([]interface{})
|
||||
if len(kvs) == 0 {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release returned no kvs")
|
||||
}
|
||||
kvm := make(map[string]string, len(kvs))
|
||||
for _, item := range kvs {
|
||||
kv, _ := item.(map[string]interface{})
|
||||
if kv == nil {
|
||||
continue
|
||||
}
|
||||
k, _ := kv["key"].(string)
|
||||
v, _ := kv["value"].(string)
|
||||
if k != "" {
|
||||
kvm[k] = v
|
||||
}
|
||||
}
|
||||
uploadURL := kvm["upload_url"]
|
||||
tosPath := kvm["tos_path"]
|
||||
if uploadURL == "" || tosPath == "" {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path")
|
||||
}
|
||||
|
||||
// Step 2: upload tar.gz to TOS via presigned URL (bypasses Lark gateway).
|
||||
//nolint:forbidigo // presigned TOS upload bypasses the Lark gateway — raw http is required; not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(tarball.Body))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = tarball.Size
|
||||
req.Header.Set("Content-Type", "application/gzip")
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload, see above.
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"app_id": spec.AppID,
|
||||
"tos_path": tosPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,21 +6,10 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -593,216 +582,3 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runHTMLPublishTOS tests ──
|
||||
|
||||
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
|
||||
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
|
||||
type permissiveFIOProvider struct{}
|
||||
|
||||
func (permissiveFIOProvider) Name() string { return "test-permissive" }
|
||||
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
|
||||
|
||||
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
|
||||
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
|
||||
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
factory.FileIOProvider = permissiveFIOProvider{}
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+tos-test"},
|
||||
cfg, factory, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_Success(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server to accept the TOS upload.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("TOS upload method = %s, want POST", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
|
||||
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
// Register pre_release API stub.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["app_id"] != "app_tos" {
|
||||
t.Fatalf("app_id=%v, want app_tos", out["app_id"])
|
||||
}
|
||||
if out["tos_path"] != "tos://bucket/key" {
|
||||
t.Fatalf("tos_path=%v, want tos://bucket/key", out["tos_path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file that is NOT named index.html.
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
rt, _ := newTOSTestRuntime(t)
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: dir,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "index.html") {
|
||||
t.Fatalf("error should mention index.html, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns an error code.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(99999),
|
||||
"msg": "internal server error",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from pre_release API failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns empty kvs list.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns no kvs key at all.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server that returns 500.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from TOS upload failure")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if !strings.Contains(problem.Message, "500") {
|
||||
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -39,7 +38,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
@@ -75,7 +75,7 @@ var AppsInit = common.Shortcut{
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -85,13 +85,14 @@ var AppsInit = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
template := resolveTemplate(rctx, appID)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
|
||||
Set("checkout", "git checkout "+defaultInitBranch).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
|
||||
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
|
||||
Set("template", "derived from queryAppType (fallback: full_stack)").
|
||||
Set("template", template).
|
||||
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
@@ -121,6 +122,20 @@ func defaultCloneDir(appID string) string {
|
||||
return filepath.Join(".", appID)
|
||||
}
|
||||
|
||||
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
|
||||
// An explicit --template wins. When omitted, it should be derived from the
|
||||
// app's tech stack.
|
||||
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
|
||||
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
|
||||
// through a (future) enum. Until that lands, fall back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
// initLogf writes a one-line progress message to stderr. stdout stays reserved
|
||||
// for the structured JSON envelope, so progress never pollutes it. Callers must
|
||||
// never pass a raw repository_url (it may embed a token) — pass step names,
|
||||
@@ -311,14 +326,16 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
|
||||
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
|
||||
// conditional `skills sync`. Returns "init" or "upgrade".
|
||||
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
|
||||
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
|
||||
empty, err := isEmptyRepo(ctx, dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if empty {
|
||||
args := scaffoldInitArgs(appType, appID, sourcePath)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
|
||||
// isEmptyRepo treats a repo with no tracked files — or only the backend's
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
@@ -337,22 +354,6 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// scaffoldInitArgs builds the npx argument list for `app init`.
|
||||
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
|
||||
// when empty. sourcePath is appended as --source-path when non-empty.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", miaodaCLIPkg, "app", "init"}
|
||||
at := appType
|
||||
if at == "" {
|
||||
at = "full_stack"
|
||||
}
|
||||
base = append(base, "--app-type", at, "--app-id", appID)
|
||||
if sourcePath != "" {
|
||||
base = append(base, "--source-path", sourcePath)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
@@ -444,8 +445,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
@@ -458,9 +457,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
@@ -519,8 +515,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
|
||||
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -535,6 +530,15 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
initLogf(rctx, "Working tree clean — skipped commit/push")
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
@@ -543,25 +547,15 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"scaffold": scaffold,
|
||||
"committed": committed,
|
||||
"pushed": pushed,
|
||||
"env_pulled": envPulled,
|
||||
"message": "Repository initialized. You can start developing.",
|
||||
}
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
out["env_pulled"] = envPulled
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
|
||||
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
|
||||
|
||||
@@ -20,20 +20,46 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
|
||||
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
|
||||
// at runtime via rctx.Str.
|
||||
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
|
||||
// mirroring how +init reads them at runtime via rctx.Str.
|
||||
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", defaultTemplate, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
|
||||
// "template" string flags registered, mirroring +init's runtime flag set. The
|
||||
// template flag is registered with an empty default (matching the real flag,
|
||||
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
|
||||
// omitted --template and a non-empty tpl to model an explicit one.
|
||||
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", tpl, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
|
||||
t.Errorf("explicit --template = %q, want foo", got)
|
||||
}
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
|
||||
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
// Whitespace-only --template is treated as omitted -> fallback.
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
|
||||
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTargetPath(t *testing.T) {
|
||||
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
|
||||
if err != nil {
|
||||
@@ -235,12 +261,12 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
|
||||
t.Run("ls="+ls, func(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
if err != nil || kind != "init" {
|
||||
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
|
||||
t.Errorf("app init not invoked with expected args: %v", f.calls)
|
||||
}
|
||||
if c != nil && containsAll(c, "--local") {
|
||||
@@ -254,7 +280,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||||
dir := t.TempDir() // no steering dir, no meta.json
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", "", "")
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
|
||||
if err != nil || kind != "upgrade" {
|
||||
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
|
||||
}
|
||||
@@ -273,7 +299,7 @@ func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||||
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil {
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
@@ -287,7 +313,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
|
||||
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
|
||||
t.Error("app init failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -316,12 +342,12 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
if _, ok := data["npx_skipped"]; ok {
|
||||
t.Error("npx_skipped must be removed")
|
||||
}
|
||||
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
|
||||
// and `app init` must still receive --template full_stack.
|
||||
// --template is omitted here, so resolveTemplate falls back to
|
||||
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Error("npx scaffold not invoked")
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
|
||||
t.Errorf("app init missing expected --template fallback args: %v", c)
|
||||
} else if containsAll(c, "--local") {
|
||||
t.Errorf("app init must NOT carry --local: %v", c)
|
||||
@@ -725,6 +751,22 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
var tmpl *common.Flag
|
||||
for i := range AppsInit.Flags {
|
||||
if AppsInit.Flags[i].Name == "template" {
|
||||
tmpl = &AppsInit.Flags[i]
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
t.Fatal("--template flag missing")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
|
||||
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
|
||||
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
|
||||
}
|
||||
|
||||
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
|
||||
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
|
||||
// directly rather than via parseEnvelopeData.
|
||||
@@ -745,8 +787,9 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
t.Error("dry-run must keep machine-contract key `scaffold`")
|
||||
} else if !strings.Contains(scaffold, "skills sync --local") {
|
||||
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
|
||||
strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
|
||||
}
|
||||
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
@@ -1207,7 +1250,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
|
||||
"git ls-files": {stdout: "src/x.ts\n"},
|
||||
"npx -y": {err: errors.New("sync boom")},
|
||||
}})
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
|
||||
t.Error("npx app sync failure must surface as an error")
|
||||
}
|
||||
}
|
||||
@@ -1587,7 +1630,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
@@ -1602,195 +1645,3 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "html") {
|
||||
t.Errorf("expected --template html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "modern_html") {
|
||||
t.Errorf("expected --template modern_html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "full_stack") {
|
||||
t.Errorf("expected --template full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "full_stack") {
|
||||
t.Errorf("expected --template full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
|
||||
t.Errorf("expected --template modern_html --app-id app_x, got %v", args)
|
||||
}
|
||||
for _, a := range args {
|
||||
if a == "--source-path" {
|
||||
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
|
||||
args := scaffoldInitArgs("", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("expected --template full_stack fallback, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
|
||||
t.Errorf("expected --source-path /path/to/src, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
|
||||
"git clone": {},
|
||||
"git checkout": {},
|
||||
"git ls-files": {stdout: ""},
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register a meta mock so queryAppType returns "modern_html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["app_type"] != "modern_html" {
|
||||
t.Errorf("app_type = %v, want modern_html", data["app_type"])
|
||||
}
|
||||
// Verify the scaffold used --template modern_html
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "modern_html") {
|
||||
t.Errorf("expected --template modern_html, got %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register meta mock so queryAppType returns "html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed2",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed2",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["scaffold"] != "already_initialized" {
|
||||
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
|
||||
}
|
||||
if data["app_type"] != "html" {
|
||||
t.Errorf("app_type = %v, want html", data["app_type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// appInfo represents the app object returned by GET /open-apis/spark/v1/apps/{appID}.
|
||||
type appInfo struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppType string `json:"app_type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
}
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(appRaw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var info appInfo
|
||||
if err := json.Unmarshal(b, &info); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(info.AppType)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+meta-test"},
|
||||
cfg, f, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestQueryAppType_Success(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_FullStack(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_fs",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_fs",
|
||||
"app_type": "FULL_STACK",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_Html(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_html",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_html",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_APIError(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_bad",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_no",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_empty",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_empty",
|
||||
"app_type": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
{Name: "tos-path", Desc: "TOS file path from +html-publish; when provided, deploys from TOS instead of git branch"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -40,29 +39,26 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
tosPath := strings.TrimSpace(rctx.Str("tos-path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Create a release").
|
||||
Body(buildPublishBody(branch, tosPath))
|
||||
Body(buildPublishBody(branch))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
tosPath := strings.TrimSpace(rctx.Str("tos-path"))
|
||||
path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch, tosPath))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id <release_id>`")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"release_id": common.GetString(data, "release_id"),
|
||||
"status": common.GetString(data, "status"),
|
||||
"sync": common.GetBool(data, "sync"),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -70,13 +66,10 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
|
||||
// buildPublishBody builds the create-release request body. app_id is in the
|
||||
// path, not the body. branch is included only when non-empty.
|
||||
func buildPublishBody(branch, tosPath string) map[string]interface{} {
|
||||
func buildPublishBody(branch string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
if tosPath != "" {
|
||||
body["tos_path"] = tosPath
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func TestBuildPublishBody(t *testing.T) {
|
||||
// branch included when non-empty; app_id is NOT in body (it's in the path)
|
||||
b := buildPublishBody("feat/devops", "")
|
||||
b := buildPublishBody("feat/devops")
|
||||
if b["branch"] != "feat/devops" {
|
||||
t.Errorf("body = %v", b)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestBuildPublishBody(t *testing.T) {
|
||||
t.Errorf("app_id must not be in body, got %v", b)
|
||||
}
|
||||
// branch omitted when empty
|
||||
b2 := buildPublishBody("", "")
|
||||
b2 := buildPublishBody("")
|
||||
if _, ok := b2["branch"]; ok {
|
||||
t.Errorf("branch should be omitted when empty, got %v", b2)
|
||||
}
|
||||
@@ -105,45 +105,3 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreate_SyncField(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_sync/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "456",
|
||||
"status": "publishing",
|
||||
"sync": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseCreate.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
if env.Data["release_id"] != "456" {
|
||||
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "publishing" {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
if env.Data["sync"] != true {
|
||||
t.Errorf("sync = %v, want true", env.Data["sync"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,11 +431,6 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
req.ExtraOpts = append(req.ExtraOpts, optFn)
|
||||
}
|
||||
// TODO: remove PPE headers once testing is complete and promoted to production.
|
||||
ppeHeaders := http.Header{}
|
||||
ppeHeaders.Set("x-use-ppe", "1")
|
||||
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
|
||||
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
@@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
feishu := core.Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
lark := core.Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 可选:`--dir`,clone 目标目录;省略时默认 `./<app-id>`。
|
||||
- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`。
|
||||
- 固定 checkout 分支:`sprint/default`。
|
||||
- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。
|
||||
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
```bash
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app
|
||||
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app
|
||||
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
|
||||
```
|
||||
|
||||
|
||||
@@ -21,10 +21,8 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.release_id`、`data.status` 和 `data.sync`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `sync=true` 表示同步部署(服务端等待部署完成后才返回),`sync=false` 或缺失表示异步部署。
|
||||
- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。
|
||||
- `status=finished` 表示部署已完成(同步部署时可能直接返回此状态)。
|
||||
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
265
tests/plugin_e2e/degrade_subsystem_test.go
Normal file
265
tests/plugin_e2e/degrade_subsystem_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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(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 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")
|
||||
}
|
||||
}
|
||||
39
tests/plugin_e2e/diagnostics_test.go
Normal file
39
tests/plugin_e2e/diagnostics_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
|
||||
// diagnostic commands on a fork carrying the readonly Restrict rule:
|
||||
// - `config policy show`: source_name == the plugin name that installed the
|
||||
// active rule.
|
||||
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
|
||||
// "hooks":{...}}],"total":N} with the readonly plugin present.
|
||||
func TestDiagnostics(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
|
||||
pol := run(t, bin, "config", "policy", "show")
|
||||
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
|
||||
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
|
||||
}
|
||||
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
|
||||
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
|
||||
}
|
||||
|
||||
plug := run(t, bin, "config", "plugins", "show")
|
||||
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
|
||||
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
|
||||
}
|
||||
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
|
||||
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
|
||||
}
|
||||
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
|
||||
t.Errorf("plugins.0.name=%q want readonly", name)
|
||||
}
|
||||
}
|
||||
210
tests/plugin_e2e/harness.go
Normal file
210
tests/plugin_e2e/harness.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package plugin_e2e exercises the extension/platform plugin contract the way a
|
||||
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
|
||||
// then runs that fork as a subprocess and asserts the real stderr/stdout
|
||||
// envelopes and exit codes. This is L4 coverage — the in-process unit and
|
||||
// integration tests (extension/..., cmd/...) assert Go error values in the test
|
||||
// process and structurally cannot observe envelope serialization, exit codes, or
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain.
|
||||
//
|
||||
// Mechanism (the "customer build", mirrors xcaddy's build mode):
|
||||
// 1. `git archive HEAD` a clean tree containing only committed files (so the
|
||||
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
|
||||
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
|
||||
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
|
||||
// + plugin package (its init() calls platform.Register).
|
||||
// 3. `go build` the fork (offline-capable via the warm module cache), then run
|
||||
// it as a subprocess and assert.
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cleanTree is the git-archived, committed-only source tree of the repo under
|
||||
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
|
||||
// TestMain must live in a _test.go file to be recognized by `go test`, so the
|
||||
// entry point sits there while the rest of the harness mechanism lives here.
|
||||
var cleanTree string
|
||||
|
||||
// baseDir holds the archive tree plus every generated customer module.
|
||||
var baseDir string
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working directory
|
||||
// (which `go test` sets to the package dir, tests/plugin_e2e).
|
||||
func repoRoot() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
|
||||
// per command, so a shared strings.Builder would be written concurrently by
|
||||
// both (git archive and tar run in parallel) -- a data race, since
|
||||
// strings.Builder is not concurrency-safe.
|
||||
var archiveErr, extractErr strings.Builder
|
||||
archive.Stderr = &archiveErr
|
||||
extract.Stderr = &extractErr
|
||||
if err := extract.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := archive.Run(); err != nil {
|
||||
_ = extract.Wait()
|
||||
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
|
||||
}
|
||||
if err := extract.Wait(); err != nil {
|
||||
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// builtForks caches fork binaries by name so identical forks are built once.
|
||||
var builtForks = map[string]string{}
|
||||
|
||||
// buildFork generates a customer module whose plugin package body is pluginSrc,
|
||||
// builds the fork, and returns the binary path. Forks are cached by name.
|
||||
func buildFork(t *testing.T, name, pluginSrc string) string {
|
||||
t.Helper()
|
||||
if bin, ok := builtForks[name]; ok {
|
||||
return bin
|
||||
}
|
||||
mod := filepath.Join(baseDir, "fork-"+name)
|
||||
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir customer module: %v", err)
|
||||
}
|
||||
|
||||
// go.mod: reuse cli's require graph, rename the module, replace cli with the
|
||||
// local archived tree. This avoids `go mod tidy` (no network at test time).
|
||||
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.mod: %v", err)
|
||||
}
|
||||
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
|
||||
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
|
||||
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
|
||||
|
||||
// go.sum: transitive dependency hashes are identical to cli's.
|
||||
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.sum: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
|
||||
|
||||
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
|
||||
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
|
||||
|
||||
bin := filepath.Join(mod, "fork-bin")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Dir = mod
|
||||
// -mod=mod fixes require annotations copied from cli's go.mod; the default
|
||||
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
|
||||
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
|
||||
}
|
||||
builtForks[name] = bin
|
||||
return bin
|
||||
}
|
||||
|
||||
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
`
|
||||
|
||||
// result is a subprocess run outcome.
|
||||
type result struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exit int
|
||||
}
|
||||
|
||||
// run executes the fork binary with args in an isolated, offline environment and
|
||||
// captures stdout/stderr/exit. Each call gets a fresh empty
|
||||
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
|
||||
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
|
||||
// open platform. That reproduces the bare-module customer state (no embedded
|
||||
// metadata, cold cache) deterministically on any machine, including CI: without
|
||||
// it, whether a command's assertion is reached depends on whether a live network
|
||||
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
|
||||
// via runWithSeededCatalog.
|
||||
func run(t *testing.T, bin string, args ...string) result {
|
||||
t.Helper()
|
||||
return runWithEnv(t, bin, isolatedEnv(t), args...)
|
||||
}
|
||||
|
||||
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
|
||||
// base) by runWithSeededCatalog.
|
||||
func isolatedEnv(t *testing.T) []string {
|
||||
t.Helper()
|
||||
return 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",
|
||||
)
|
||||
}
|
||||
|
||||
// runWithEnv runs bin as a subprocess with the given full environment, capturing
|
||||
// stdout/stderr/exit.
|
||||
func runWithEnv(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 = env
|
||||
var stdout, stderr strings.Builder
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("run %v: %v", args, err)
|
||||
}
|
||||
}
|
||||
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
339
tests/plugin_e2e/install_test.go
Normal file
339
tests/plugin_e2e/install_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// multipleRestrictPlugin registers TWO distinct plugins that each call
|
||||
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
|
||||
// more than one distinct Restrict-owner regardless of each rule's own
|
||||
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
|
||||
// before ValidateRule).
|
||||
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-a", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "a-rule",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-b", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "b-rule",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
|
||||
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
|
||||
// every RunE in the tree so it fires regardless of which command runs):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"multiple plugins called Restrict; only one plugin may own the
|
||||
// policy: [restrict-a restrict-b]",
|
||||
// "hint":"plugin policy configuration is broken (reason_code
|
||||
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
|
||||
// conflicting plugin"}}
|
||||
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
|
||||
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
|
||||
}
|
||||
|
||||
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
|
||||
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
|
||||
// Registrar validate Rule *contents* (only nilness) -- semantic validation
|
||||
// happens later, in cmdpolicy.ValidateRule, called from
|
||||
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
|
||||
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("invalid-rule", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bad-risk",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.Risk("bogus"),
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
|
||||
// must be one of read|write|high-risk-write",
|
||||
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
|
||||
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
|
||||
func TestInstallInvalidRulePin(t *testing.T) {
|
||||
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "invalid_rule")
|
||||
}
|
||||
|
||||
// installFailedPlugin is a hand-written bare platform.Plugin (not
|
||||
// Builder-based -- Install returning a plain error is not expressible
|
||||
// through the Builder's fluent API) whose Install always returns an error.
|
||||
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
|
||||
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type installFailed struct{}
|
||||
|
||||
func (installFailed) Name() string { return "install-failed" }
|
||||
func (installFailed) Version() string { return "0.1.0" }
|
||||
func (installFailed) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installFailed) Install(r platform.Registrar) error {
|
||||
return errors.New("deliberate install failure")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installFailed{}) }
|
||||
`
|
||||
|
||||
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-failed\" (install_failed): Install returned
|
||||
// error: deliberate install failure",
|
||||
// "hint":"plugin \"install-failed\" failed to install (reason_code
|
||||
// install_failed); fix or remove the plugin before running commands"}}
|
||||
func TestInstallFailedPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-failed", installFailedPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_failed")
|
||||
}
|
||||
|
||||
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
|
||||
// safeCallInstall (internal/platform/host.go) recovers and converts the
|
||||
// panic into a typed install_panic error rather than crashing the binary.
|
||||
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type installPanic struct{}
|
||||
|
||||
func (installPanic) Name() string { return "install-panic" }
|
||||
func (installPanic) Version() string { return "0.1.0" }
|
||||
func (installPanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installPanic) Install(r platform.Registrar) error {
|
||||
panic("deliberate install panic")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
|
||||
// deliberate install panic",
|
||||
// "hint":"plugin \"install-panic\" failed to install (reason_code
|
||||
// install_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-panic", installPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_panic")
|
||||
}
|
||||
|
||||
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
|
||||
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
|
||||
// Capabilities(), so this aborts unconditionally regardless of what
|
||||
// Capabilities() would have declared (host.go's isUntrustedConfigError
|
||||
// list) -- Capabilities() here is a throwaway zero value, never invoked.
|
||||
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type pluginNamePanic struct{}
|
||||
|
||||
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
|
||||
func (pluginNamePanic) Version() string { return "0.1.0" }
|
||||
func (pluginNamePanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{}
|
||||
}
|
||||
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(pluginNamePanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
|
||||
// output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
|
||||
// panicked: deliberate name panic",
|
||||
// "hint":"plugin \"<unknown>\" failed to install (reason_code
|
||||
// plugin_name_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPluginNamePanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
|
||||
}
|
||||
|
||||
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
|
||||
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
|
||||
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
|
||||
// recover leaves the pre-set FailClosed default in place -- so this aborts
|
||||
// unconditionally too, without the plugin ever declaring a real policy.
|
||||
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type capabilitiesPanic struct{}
|
||||
|
||||
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
|
||||
func (capabilitiesPanic) Version() string { return "0.1.0" }
|
||||
func (capabilitiesPanic) Capabilities() platform.Capabilities {
|
||||
panic("deliberate capabilities panic")
|
||||
}
|
||||
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(capabilitiesPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
|
||||
// real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
|
||||
// Plugin.Capabilities() panicked: deliberate capabilities panic",
|
||||
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
|
||||
// capabilities_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallCapabilitiesPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "capabilities_panic")
|
||||
}
|
||||
|
||||
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
|
||||
// Capabilities.Restricts=true (paired with the required FailClosed) but
|
||||
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
|
||||
// (internal/platform/staging.go) checks this exact declared-vs-actual
|
||||
// consistency after Install returns.
|
||||
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type restrictsMismatch struct{}
|
||||
|
||||
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
|
||||
func (restrictsMismatch) Version() string { return "0.1.0" }
|
||||
func (restrictsMismatch) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(restrictsMismatch{}) }
|
||||
`
|
||||
|
||||
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
|
||||
// Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
|
||||
// Capabilities.Restricts=true but Install did not call r.Restrict",
|
||||
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
|
||||
// restricts_mismatch); fix or remove the plugin before running commands"}}
|
||||
func TestInstallRestrictsMismatchPin(t *testing.T) {
|
||||
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
|
||||
}
|
||||
|
||||
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
|
||||
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
|
||||
// plugin.init(), which runs from the blank-import BEFORE main() has a
|
||||
// chance to install any recover-and-envelope guard -- so, unlike every
|
||||
// other case here, this crashes the process outright: no JSON envelope,
|
||||
// non-zero exit, a raw Go panic trace on stderr.
|
||||
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
|
||||
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
|
||||
// crash, because it happens in init(), before main()'s recover guard
|
||||
// exists. Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
|
||||
// match ^[a-z0-9][a-z0-9-]*$
|
||||
//
|
||||
// goroutine 1 [running]:
|
||||
// larkcustomer/plugin.init.0(...)
|
||||
// .../plugin/plugin.go:7
|
||||
// ...
|
||||
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
|
||||
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit == 0 {
|
||||
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) {
|
||||
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "panic:") {
|
||||
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
|
||||
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
298
tests/plugin_e2e/observe_wrap_test.go
Normal file
298
tests/plugin_e2e/observe_wrap_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// auditPlugin registers a single After observer matching every command that
|
||||
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
|
||||
// shipped extension/platform/examples/audit-observer example.
|
||||
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObservePin pins the audit observer's stderr line format. Observed
|
||||
// real output (docs +fetch --doc nonexistent, a real read-risk command that
|
||||
// fails downstream with an API error unrelated to the plugin):
|
||||
//
|
||||
// exit=1
|
||||
// stderr=[audit] docs/+fetch
|
||||
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
|
||||
//
|
||||
// The observer line always leads, on its own line, before whatever the
|
||||
// command itself writes to stderr.
|
||||
func TestObservePin(t *testing.T) {
|
||||
bin := buildFork(t, "audit", auditPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
|
||||
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// auditRestrictPlugin combines an After observer with a Restrict rule in one
|
||||
// plugin, so a denied command's stderr carries both the observer's
|
||||
// side-effect and the denial envelope: the framework's contract is that
|
||||
// After observers fire even for denied commands (see
|
||||
// extension/platform/invocation.go's DeniedByPolicy doc).
|
||||
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit-restrict", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
|
||||
// stderr carries BOTH the observer's audit line AND the denial envelope,
|
||||
// concatenated in a single stream, audit line first. Observed real output
|
||||
// (docs +update --doc-token x --content y, denied write_not_allowed):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=[audit] docs/+update
|
||||
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
|
||||
//
|
||||
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
|
||||
// false; the JSON envelope must be sliced out from the first '{' before
|
||||
// parsing it as JSON.
|
||||
func TestObserveOnDeniedPin(t *testing.T) {
|
||||
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
|
||||
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
|
||||
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
|
||||
}
|
||||
i := strings.Index(res.stderr, "{")
|
||||
if i < 0 {
|
||||
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
envelope := res.stderr[i:]
|
||||
if !gjson.Valid(envelope) {
|
||||
t.Fatalf("sliced envelope not JSON: %s", envelope)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
|
||||
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
|
||||
// (internal/hook/install.go) must isolate the panic so command dispatch
|
||||
// still completes normally.
|
||||
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("observer-panic", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {
|
||||
panic("boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
|
||||
// always panics must not affect the command's own outcome. The assertion is
|
||||
// baseline-relative -- the panicking-observer fork's exit code must equal the
|
||||
// noop-observer baseline fork's for the same `schema` command (a local,
|
||||
// network-free, read-risk command), whatever that shared exit code is.
|
||||
// Observed real output at pin time:
|
||||
//
|
||||
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
|
||||
// baseline: exit=0 stderr=(empty)
|
||||
//
|
||||
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
|
||||
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
|
||||
func TestObserverPanicIsolationPin(t *testing.T) {
|
||||
bin := buildFork(t, "observer-panic", observerPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
|
||||
baselineBin := buildFork(t, "smoke", noopPlugin)
|
||||
baseline := run(t, baselineBin, "schema")
|
||||
|
||||
if res.exit != baseline.exit {
|
||||
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
|
||||
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
|
||||
// instead of calling next.
|
||||
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-abort", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "guard",
|
||||
Reason: "blocked for test",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
|
||||
// returned by a Wrapper is converted by wrapAbortError
|
||||
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
|
||||
// denial -- error.type=="validation", error.subtype=="failed_precondition"
|
||||
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
|
||||
// --doc nonexistent, wrapper aborts unconditionally before calling next):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
|
||||
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
|
||||
// request to satisfy the hook's policy, or remove the plugin"}}
|
||||
//
|
||||
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
|
||||
// regardless of the HookName the plugin set on the AbortError itself
|
||||
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
|
||||
func TestWrapAbortPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
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 msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the abort hint", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
|
||||
// closure itself, not the returned Handler).
|
||||
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-panic", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
panic("wrap boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
|
||||
// factory does not crash the process. recoverWrap (internal/hook/install.go)
|
||||
// converts the panic into the same validation/failed_precondition shape as
|
||||
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
|
||||
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
|
||||
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
|
||||
// command; report the panic to the plugin author or remove the plugin"}}
|
||||
func TestWrapPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
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 msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the panic hint", hint)
|
||||
}
|
||||
}
|
||||
205
tests/plugin_e2e/restrict_test.go
Normal file
205
tests/plugin_e2e/restrict_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// readonlyPlugin registers a Restrict rule that only allows read-risk
|
||||
// commands under the docs/** and im/** domains. It mirrors the official
|
||||
// example readonly-policy configuration.
|
||||
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
|
||||
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
|
||||
// error.hint contains the literal "reason_code <X>" substring, and the
|
||||
// process exits 2. reason_code lives only in the hint string, not a
|
||||
// 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
|
||||
reasonCode string
|
||||
}{
|
||||
{"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"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadonlyAllows asserts the allow-path: a read command inside an
|
||||
// allowed domain must NOT be denied by the policy gate. It may still fail
|
||||
// downstream (e.g. api/auth error), but that failure must not carry the
|
||||
// denial envelope shape and must not exit 2.
|
||||
func TestReadonlyAllows(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit == 2 {
|
||||
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
|
||||
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// identityPlugin registers a Restrict rule scoped to bot identities only.
|
||||
// im +messages-search declares AuthTypes:["user"] (see
|
||||
// shortcuts/im/im_messages_search.go), so it has no intersection with the
|
||||
// rule's bot-only whitelist regardless of which --as value the caller
|
||||
// passes: platform.Rule.Identities is checked against the command's own
|
||||
// static supported-identities annotation, not the runtime --as flag.
|
||||
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("identity-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bot-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
Identities: []platform.Identity{platform.IdentityBot},
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// denylistPlugin registers a Restrict rule that allows the docs/** domain
|
||||
// but explicitly denies docs/+search (a real read-risk leaf, see
|
||||
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
|
||||
// command is rejected before MaxRisk is even consulted.
|
||||
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("denylist-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "deny-search",
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+search"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
|
||||
// docs-only). A command outside both domains (e.g. the top-level "schema"
|
||||
// command, itself read-risk and already proven to hit domain_not_allowed
|
||||
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
|
||||
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
|
||||
// per-rule denials into the aggregate reason_code "no_matching_rule".
|
||||
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("multi-rule-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "im-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
|
||||
// reason_code across this package -- both policy denials (this file) and
|
||||
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
|
||||
// error.type=="validation", error.subtype=="failed_precondition", and
|
||||
// error.hint containing "reason_code <wantReasonCode>". Both paths render
|
||||
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
|
||||
// reason_code in the hint STRING, not a structured error.detail.reason_code
|
||||
// field (contradicting internal/platform/error.go:34's comment).
|
||||
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
|
||||
t.Helper()
|
||||
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 "+wantReasonCode) {
|
||||
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
|
||||
// rule rejects a command whose declared AuthTypes don't include "bot".
|
||||
func TestIdentityMismatchDenial(t *testing.T) {
|
||||
bin := buildFork(t, "identity", identityPlugin)
|
||||
res := run(t, bin, "im", "+messages-search", "--as", "user")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "identity_mismatch")
|
||||
}
|
||||
|
||||
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
|
||||
// rejects the command even though it also matches Allow.
|
||||
func TestDenylistDenial(t *testing.T) {
|
||||
bin := buildFork(t, "denylist", denylistPlugin)
|
||||
res := run(t, bin, "docs", "+search")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "command_denylisted")
|
||||
}
|
||||
|
||||
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
|
||||
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
|
||||
// not either rule's own per-rule reason_code.
|
||||
func TestMultiRuleDenial(t *testing.T) {
|
||||
bin := buildFork(t, "multirule", multiRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "no_matching_rule")
|
||||
}
|
||||
69
tests/plugin_e2e/smoke_test.go
Normal file
69
tests/plugin_e2e/smoke_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain archives HEAD's committed tree once for the whole package before
|
||||
// any fork build runs. It lives here (not in harness.go) because `go test`
|
||||
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
|
||||
// .go file is silently never invoked.
|
||||
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
panic("locate repo root: " + err.Error())
|
||||
}
|
||||
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
|
||||
if err != nil {
|
||||
panic("mkdtemp: " + err.Error())
|
||||
}
|
||||
cleanTree = filepath.Join(baseDir, "larkcli-clean")
|
||||
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
|
||||
panic("mkdir clean tree: " + err.Error())
|
||||
}
|
||||
if err := gitArchive(root, cleanTree); err != nil {
|
||||
panic("git archive: " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(baseDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// noopPlugin registers a plugin that installs nothing observable, proving
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain links
|
||||
// and the fork boots.
|
||||
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("smoke", "0.0.1").
|
||||
Observer(platform.After, "noop", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
func TestSmokeForkBoots(t *testing.T) {
|
||||
bin := buildFork(t, "smoke", noopPlugin)
|
||||
res := run(t, bin, "--help")
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
|
||||
}
|
||||
if res.stdout == "" {
|
||||
t.Fatalf("--help produced empty stdout")
|
||||
}
|
||||
}
|
||||
466
tests/sidecar_e2e/roundtrip_test.go
Normal file
466
tests/sidecar_e2e/roundtrip_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
|
||||
// offline and secret-free: a real fork binary (built with -tags authsidecar,
|
||||
// exercising the REAL extension/transport/sidecar interceptor) signs a
|
||||
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
|
||||
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
|
||||
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
|
||||
// forwards to an in-test mock upstream.
|
||||
//
|
||||
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
|
||||
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
|
||||
// middle process. That is infeasible for an OFFLINE test, for three
|
||||
// independent reasons, all verified in source:
|
||||
//
|
||||
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
|
||||
// h.cred.ResolveToken(...), which errors out unless the machine has run
|
||||
// `lark-cli auth login` — there is no way to make it return a token
|
||||
// without live credentials.
|
||||
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
|
||||
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
|
||||
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
|
||||
// and would be rejected with 403 (handler.go step 4).
|
||||
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
|
||||
// "https://" + targetHost, ignoring the client-supplied scheme. It can
|
||||
// never be redirected to an http:// mock.
|
||||
//
|
||||
// server-demo's verify+inject logic is ALREADY covered by
|
||||
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
|
||||
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
|
||||
// same code paths this file would otherwise exercise via a real subprocess.
|
||||
//
|
||||
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
|
||||
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
|
||||
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
|
||||
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
|
||||
// same symbols server-demo itself uses. This is the standard shape for this
|
||||
// kind of test: one real external process (the fork binary, compiled with
|
||||
// the production interceptor code) plus two in-process httptest.Server
|
||||
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
|
||||
// without requiring live credentials, real feishu/lark hosts, or TLS.
|
||||
//
|
||||
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
|
||||
// in this file can authenticate against anything real.
|
||||
package sidecar_e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Synthetic, obviously-fake fixtures. None of these are real secrets.
|
||||
const (
|
||||
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
|
||||
testAppID = "cli_test_app_not_real"
|
||||
injectedToken = "fake-injected-token-not-real"
|
||||
)
|
||||
|
||||
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
|
||||
// steps so the flow is readable at a glance; each step's mechanics live in a
|
||||
// dedicated helper below.
|
||||
func TestSidecarHMACRoundTrip(t *testing.T) {
|
||||
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
|
||||
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
|
||||
upstream := startMockUpstream(t)
|
||||
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
|
||||
|
||||
// One real external process: lark-cli built with -tags authsidecar, run
|
||||
// fully offline against the in-test sidecar.
|
||||
bin := buildAuthsidecarFork(t)
|
||||
runFork(t, bin, sc.URL)
|
||||
|
||||
// Assert the three properties of a correct round trip.
|
||||
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
|
||||
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
|
||||
}
|
||||
|
||||
// --- request capture -------------------------------------------------------
|
||||
|
||||
// capturedRequest snapshots the parts of an *http.Request that matter for
|
||||
// assertions, taken before the request (and its body reader) is consumed or
|
||||
// goes out of scope.
|
||||
type capturedRequest struct {
|
||||
method string
|
||||
path string
|
||||
headers http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// requestSink stores the request a stub server saw, guarded so the httptest
|
||||
// handler goroutine and the test goroutine can hand it over safely.
|
||||
type requestSink struct {
|
||||
mu sync.Mutex
|
||||
req *capturedRequest
|
||||
}
|
||||
|
||||
func (s *requestSink) capture(r *http.Request, body []byte) {
|
||||
snap := capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.RequestURI(),
|
||||
headers: r.Header.Clone(),
|
||||
body: body,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.req = &snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *requestSink) get() *capturedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.req
|
||||
}
|
||||
|
||||
// --- mock upstream (stands in for open.feishu.cn) --------------------------
|
||||
|
||||
type mockUpstream struct {
|
||||
*httptest.Server
|
||||
sink requestSink
|
||||
}
|
||||
|
||||
func startMockUpstream(t *testing.T) *mockUpstream {
|
||||
t.Helper()
|
||||
m := &mockUpstream{}
|
||||
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
m.sink.capture(r, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
|
||||
}))
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
|
||||
|
||||
type inTestSidecar struct {
|
||||
*httptest.Server
|
||||
key []byte
|
||||
upstreamURL string
|
||||
sink requestSink
|
||||
|
||||
mu sync.Mutex // guards verifyRan/verifyErr
|
||||
verifyRan bool
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
|
||||
t.Helper()
|
||||
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
|
||||
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.sink.capture(r, body)
|
||||
|
||||
authHeader, ok := s.verifyProxyRequest(w, r, body)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.forwardWithInjectedToken(w, r, body, authHeader)
|
||||
}
|
||||
|
||||
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
|
||||
// version, body SHA256, target validation, and HMAC signature verification.
|
||||
// It records whether verification ran and its result (for assertions) and
|
||||
// returns the auth header the client committed to. On any failure it writes
|
||||
// the HTTP error and returns ok=false.
|
||||
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
|
||||
// Step 0: protocol version.
|
||||
version := r.Header.Get(sidecar.HeaderProxyVersion)
|
||||
if version != sidecar.ProtocolV1 {
|
||||
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 1-2: timestamp + body SHA256.
|
||||
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
||||
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
||||
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
|
||||
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 3: target host, identity, auth-header (all covered by the sig).
|
||||
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
|
||||
if perr != nil {
|
||||
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
||||
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
||||
|
||||
// Step 4: verify HMAC signature over the canonical request.
|
||||
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: r.URL.RequestURI(),
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}, r.Header.Get(sidecar.HeaderProxySignature))
|
||||
s.mu.Lock()
|
||||
s.verifyRan = true
|
||||
s.verifyErr = err
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
return authHeader, true
|
||||
}
|
||||
|
||||
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
|
||||
// server-demo (which forwards to "https://"+targetHost), this test forwards to
|
||||
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
|
||||
// real upstream or a route to targetHost. It strips any client-supplied auth
|
||||
// headers first (the sidecar is the sole source of auth material), injects the
|
||||
// synthetic token into the committed header, and relays the response back.
|
||||
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
|
||||
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
freq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
freq.Header.Del("Authorization")
|
||||
freq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
freq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
if authHeader == "Authorization" {
|
||||
freq.Header.Set("Authorization", "Bearer "+injectedToken)
|
||||
} else {
|
||||
freq.Header.Set(authHeader, injectedToken)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(freq)
|
||||
if err != nil {
|
||||
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(respBody)
|
||||
}
|
||||
|
||||
// verifyResult reports whether step 4 ran and, if so, its error.
|
||||
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.verifyRan, s.verifyErr
|
||||
}
|
||||
|
||||
// isProxyHeader reports whether name is one of the sidecar wire-protocol
|
||||
// headers that must not be copied through to the forwarded (mock upstream)
|
||||
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
|
||||
func isProxyHeader(name string) bool {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
|
||||
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
|
||||
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
|
||||
// is used, both as HMAC signing input and to record what the fork believed
|
||||
// its real destination was — the actual forward in this test always goes to
|
||||
// the in-test mock, never to this host.
|
||||
func parseTargetHost(target string) (string, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("missing host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("userinfo not allowed")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("query not allowed")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment not allowed")
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
|
||||
// --- fork build + run ------------------------------------------------------
|
||||
|
||||
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
|
||||
// production interceptor) and returns the binary path.
|
||||
func buildAuthsidecarFork(t *testing.T) string {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "forkbin")
|
||||
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
|
||||
build.Dir = repoRoot(t)
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork binary: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
|
||||
// exit status is logged but NOT asserted — this test judges wire behavior
|
||||
// (what reached the sidecar/upstream), not the command's own success.
|
||||
func runFork(t *testing.T, binPath, sidecarURL string) {
|
||||
t.Helper()
|
||||
scURL, err := url.Parse(sidecarURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sidecar URL: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
|
||||
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
|
||||
"LARKSUITE_CLI_APP_ID="+testAppID,
|
||||
"LARKSUITE_CLI_BRAND=feishu",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
|
||||
t.Logf("fork stdout: %s", stdout.String())
|
||||
t.Logf("fork stderr: %s", stderr.String())
|
||||
}
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working
|
||||
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repo root: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// --- assertions ------------------------------------------------------------
|
||||
|
||||
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
|
||||
// the real interceptor ran (all proxy headers present, identity=user), stripped
|
||||
// every real/sentinel auth header before signing, and produced a signature that
|
||||
// verified against the shared key.
|
||||
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
|
||||
t.Helper()
|
||||
got := sc.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
|
||||
}
|
||||
ran, verifyErr := sc.verifyResult()
|
||||
if !ran {
|
||||
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
|
||||
}
|
||||
if verifyErr != nil {
|
||||
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
|
||||
}
|
||||
t.Logf("fork->sidecar headers: %v", got.headers)
|
||||
|
||||
// No real/sentinel auth ever left the fork: the interceptor strips the
|
||||
// sentinel before signing, so this hop must carry no auth header at all.
|
||||
if auth := got.headers.Get("Authorization"); auth != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
|
||||
}
|
||||
|
||||
// Proxy headers must be present (proves the interceptor actually ran).
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if got.headers.Get(h) == "" {
|
||||
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
|
||||
}
|
||||
}
|
||||
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
|
||||
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
|
||||
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
|
||||
// never a sentinel or a real one — proving injection actually happened.
|
||||
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
|
||||
t.Helper()
|
||||
got := up.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
|
||||
}
|
||||
t.Logf("sidecar->mock headers: %v", got.headers)
|
||||
|
||||
wantAuth := "Bearer " + injectedToken
|
||||
gotAuth := got.headers.Get("Authorization")
|
||||
if gotAuth != wantAuth {
|
||||
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
|
||||
}
|
||||
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
|
||||
// proving the only token that ever reached "upstream" was the injected one.
|
||||
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
|
||||
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user