mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
691 lines
27 KiB
Go
691 lines
27 KiB
Go
// 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)
|
|
// built on the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
|
|
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
|
|
// same symbols server-demo itself uses. Against server-demo/handler.go's
|
|
// numbered steps, the coverage accounting is:
|
|
//
|
|
// - steps 0-3 (protocol version, timestamp presence, body SHA256, HMAC
|
|
// verification): MIRRORED in the in-test handler. Note server-demo's
|
|
// step 1 checks timestamp presence only — no freshness/skew window
|
|
// exists there either; the timestamp's integrity is covered by the HMAC.
|
|
// - steps 4/5/5.5 (target-host / identity / auth-header allowlists): NOT
|
|
// enforced in the handler (a mock's 127.0.0.1 host can never be in a
|
|
// real allowlist); replaced by post-hoc test assertions that the docs
|
|
// request named the real Feishu host, identity=user, and the committed
|
|
// auth header was present.
|
|
// - step 6 (resolve real token): replaced by a synthetic injected token —
|
|
// the point of the offline design.
|
|
// - steps 7-10 (build forward request, inject, forward, relay response):
|
|
// mirrored in shape, except the forward goes to the in-test mock's URL
|
|
// instead of "https://"+targetHost (deliberate, documented on
|
|
// forwardWithInjectedToken).
|
|
// - step 11 (audit log): not covered; irrelevant to the wire contract.
|
|
//
|
|
// 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"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"
|
|
|
|
// testDocToken is the --doc argument runFork passes; the docs +fetch call
|
|
// becomes POST /open-apis/docs_ai/v1/documents/<testDocToken>/fetch. Sharing
|
|
// it keeps the request marker below in sync with the command invocation.
|
|
testDocToken = "nonexistent"
|
|
|
|
// docsReqPath is the exact path of the TARGET docs +fetch request among
|
|
// every request the fork routes through the proxy. `docs +fetch --as user`
|
|
// resolves a sentinel UAT, and the credential layer then verifies it with
|
|
// a mandatory /open-apis/authen/v1/user_info probe (see
|
|
// internal/credential/credential_provider.go enrichUserInfo) — so a second
|
|
// request also flows through the sidecar. Asserting on whichever arrived
|
|
// last would let that identity probe masquerade as the docs request; we
|
|
// select the docs call by its full path (exact match, not a substring — a
|
|
// wrong API prefix or version must not slip through).
|
|
docsReqPath = "/open-apis/docs_ai/v1/documents/" + testDocToken + "/fetch"
|
|
|
|
// wantProxyTargetHost is the real Feishu open-platform host the interceptor
|
|
// must name as the proxy target for BRAND=feishu. The request is never
|
|
// actually forwarded there (the in-test sidecar redirects to the mock); the
|
|
// header only records where the fork BELIEVED it was going, and it is HMAC
|
|
// signing input, so it must be exactly the real host.
|
|
wantProxyTargetHost = "open.feishu.cn"
|
|
)
|
|
|
|
// 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)
|
|
res := runFork(t, bin, sc.URL)
|
|
|
|
// Diagnostic dump (shown only on failure or -v): the full request set, so a
|
|
// failure makes plain which requests flowed and which one the assertions
|
|
// targeted, instead of guessing about the last-arriving request.
|
|
for _, s := range sc.seenAll() {
|
|
t.Logf("sidecar saw: %s %s target=%q identity=%q verifyRan=%v verifyErr=%v",
|
|
s.req.method, s.req.path, s.req.headers.Get(sidecar.HeaderProxyTarget),
|
|
s.req.headers.Get(sidecar.HeaderProxyIdentity), s.verifyRan, s.verifyErr)
|
|
}
|
|
for _, r := range upstream.sink.all() {
|
|
t.Logf("upstream saw: %s %s auth=%q", r.method, r.path, r.headers.Get("Authorization"))
|
|
}
|
|
t.Logf("fork exit=%d\nstdout=%s\nstderr=%s", res.exit, res.stdout, res.stderr)
|
|
|
|
// Assert the fork's command itself succeeded end-to-end, not just that some
|
|
// bytes reached the sidecar.
|
|
assertForkSucceeded(t, res)
|
|
|
|
// Assert the three properties of a correct round trip, scoped to the DOCS
|
|
// request (not an auxiliary identity probe).
|
|
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 EVERY request a stub server saw, in arrival order,
|
|
// guarded so the httptest handler goroutine and the test goroutine can hand
|
|
// them over safely. Capturing all requests (not just the last) is what closes
|
|
// the false-green window: the fork may route more than one request through the
|
|
// proxy, and the target docs request is not guaranteed to be the last.
|
|
type requestSink struct {
|
|
mu sync.Mutex
|
|
reqs []*capturedRequest
|
|
}
|
|
|
|
func (s *requestSink) capture(r *http.Request, body []byte) *capturedRequest {
|
|
snap := &capturedRequest{
|
|
method: r.Method,
|
|
path: r.URL.RequestURI(),
|
|
headers: r.Header.Clone(),
|
|
body: body,
|
|
}
|
|
s.mu.Lock()
|
|
s.reqs = append(s.reqs, snap)
|
|
s.mu.Unlock()
|
|
return snap
|
|
}
|
|
|
|
func (s *requestSink) all() []*capturedRequest {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]*capturedRequest(nil), s.reqs...)
|
|
}
|
|
|
|
// find returns the first captured request whose path equals path, or nil.
|
|
func (s *requestSink) find(path string) *capturedRequest {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, r := range s.reqs {
|
|
if r.path == path {
|
|
return r
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- 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)
|
|
// Respond per path so each forwarded request parses as success: the
|
|
// identity probe needs authen/v1/user_info's {data:{open_id,name}} to
|
|
// resolve cleanly; the docs +fetch is satisfied by the generic code:0
|
|
// envelope. A single canned body would make the identity probe error.
|
|
_, _ = w.Write(mockResponseFor(r.URL.Path))
|
|
}))
|
|
t.Cleanup(m.Close)
|
|
return m
|
|
}
|
|
|
|
// mockResponseFor returns a minimal success body matching the API the given
|
|
// path addresses. Unknown paths get a generic code:0 envelope.
|
|
func mockResponseFor(path string) []byte {
|
|
if strings.Contains(path, "/authen/v1/user_info") {
|
|
return []byte(`{"code":0,"msg":"success","data":{"open_id":"ou_mock","name":"mock user"}}`)
|
|
}
|
|
return []byte(`{"code":0,"msg":"success","data":{}}`)
|
|
}
|
|
|
|
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
|
|
|
|
// sidecarSeen is one request the in-test sidecar received, together with the
|
|
// per-request verification outcome. Tracking this per request (not as a single
|
|
// last-write-wins field) lets assertions check the verification that belongs
|
|
// to the DOCS request specifically.
|
|
type sidecarSeen struct {
|
|
req *capturedRequest
|
|
verifyRan bool
|
|
verifyErr error
|
|
}
|
|
|
|
type inTestSidecar struct {
|
|
*httptest.Server
|
|
key []byte
|
|
upstreamURL string
|
|
|
|
mu sync.Mutex // guards seen
|
|
seen []sidecarSeen
|
|
}
|
|
|
|
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-3) -> inject+forward.
|
|
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
snap := &capturedRequest{
|
|
method: r.Method,
|
|
path: r.URL.RequestURI(),
|
|
headers: r.Header.Clone(),
|
|
body: body,
|
|
}
|
|
|
|
authHeader, verifyRan, verifyErr, ok := s.verifyProxyRequest(w, r, body)
|
|
s.mu.Lock()
|
|
s.seen = append(s.seen, sidecarSeen{req: snap, verifyRan: verifyRan, verifyErr: verifyErr})
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
s.forwardWithInjectedToken(w, r, body, authHeader)
|
|
}
|
|
|
|
// verifyProxyRequest mirrors server-demo/handler.go steps 0-3 (protocol
|
|
// version, timestamp presence, body SHA256, HMAC signature verification —
|
|
// including the target parse and identity/auth-header reads that feed the
|
|
// canonical signing string). The allowlist steps 4/5/5.5 are intentionally
|
|
// absent; the package comment's coverage accounting explains what replaces
|
|
// them. It returns the auth header the client committed to, whether HMAC
|
|
// verification (step 3) actually ran, and its result. On any earlier failure
|
|
// it writes the HTTP error and returns ok=false with verifyRan=false.
|
|
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, verifyRan bool, verifyErr error, 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, nil, false
|
|
}
|
|
|
|
// Step 1: timestamp presence (matching server-demo, which enforces
|
|
// presence only — the value's integrity is covered by the HMAC below;
|
|
// an empty-but-signed timestamp would otherwise verify fine).
|
|
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
|
if ts == "" {
|
|
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
|
|
return "", false, nil, false
|
|
}
|
|
|
|
// Step 2: body SHA256.
|
|
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
|
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
|
|
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
|
return "", false, nil, false
|
|
}
|
|
|
|
// Step 3 inputs: 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)
|
|
// verifyRan=false: HMAC verification never ran; surface the parse error
|
|
// so the diagnostic dump shows why this request was rejected early.
|
|
return "", false, perr, false
|
|
}
|
|
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
|
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
|
|
|
// Step 3: 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))
|
|
if err != nil {
|
|
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
|
return "", true, err, false
|
|
}
|
|
return authHeader, true, nil, 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)
|
|
}
|
|
|
|
// seenAll returns a copy of every request the sidecar received, in order.
|
|
func (s *inTestSidecar) seenAll() []sidecarSeen {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]sidecarSeen(nil), s.seen...)
|
|
}
|
|
|
|
// findSeen returns the first received request whose path equals path, or nil.
|
|
func (s *inTestSidecar) findSeen(path string) *sidecarSeen {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.seen {
|
|
if s.seen[i].req.path == path {
|
|
return &s.seen[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// forkResult is the fork subprocess outcome.
|
|
type forkResult struct {
|
|
exit int
|
|
stdout string
|
|
stderr string
|
|
}
|
|
|
|
// runFork runs the fork against the in-test sidecar, fully offline, and returns
|
|
// its exit code and captured output. LARKSUITE_CLI_REMOTE_META=off is essential:
|
|
// without it the fork's startup metadata refresh hits the real
|
|
// open.feishu.cn/api/tools/open/api_definition (internal/registry/remote.go),
|
|
// which both breaks the "offline, secret-free" contract and makes the run
|
|
// depend on live network. With it set, the command still completes and the
|
|
// docs request still flows through the sidecar, but nothing leaves the machine.
|
|
func runFork(t *testing.T, binPath, sidecarURL string) forkResult {
|
|
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", testDocToken, "--as", "user")
|
|
// Strip the host's LARKSUITE_CLI_* namespace before appending overrides: a
|
|
// developer machine exporting, say, LARKSUITE_CLI_DEFAULT_AS or
|
|
// LARKSUITE_CLI_STRICT_MODE would otherwise leak into the fork (the sidecar
|
|
// credential provider reads them via os.Getenv), so the fork's CLI-facing
|
|
// environment is exactly the variables set below, on any machine.
|
|
env := os.Environ()
|
|
base := env[:0]
|
|
for _, kv := range env {
|
|
if !strings.HasPrefix(kv, "LARKSUITE_CLI_") {
|
|
base = append(base, kv)
|
|
}
|
|
}
|
|
cmd.Env = append(base,
|
|
"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_REMOTE_META=off",
|
|
"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()
|
|
exit := 0
|
|
if runErr != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(runErr, &ee) {
|
|
exit = ee.ExitCode()
|
|
} else {
|
|
t.Fatalf("run fork: %v", runErr)
|
|
}
|
|
}
|
|
return forkResult{exit: exit, stdout: stdout.String(), stderr: 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 ------------------------------------------------------------
|
|
|
|
// assertForkSucceeded checks the fork command itself completed the round trip:
|
|
// exit 0 and an ok:true JSON envelope on stdout. This is what makes the docs
|
|
// request a genuine success path, not merely bytes that happened to flow.
|
|
func assertForkSucceeded(t *testing.T, res forkResult) {
|
|
t.Helper()
|
|
if res.exit != 0 {
|
|
t.Fatalf("fork exit=%d want 0; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
// Parse rather than substring-match: the CLI pretty-prints stdout, so the
|
|
// envelope reads "ok": true (with a space), and the field's truth — not its
|
|
// serialized spelling — is what proves the round trip succeeded.
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := json.Unmarshal([]byte(res.stdout), &env); err != nil {
|
|
t.Fatalf("fork stdout is not a JSON envelope: %v; stdout=%s stderr=%s", err, res.stdout, res.stderr)
|
|
}
|
|
if !env.OK {
|
|
t.Fatalf("fork stdout ok != true (round trip did not succeed); stdout=%s stderr=%s", res.stdout, res.stderr)
|
|
}
|
|
}
|
|
|
|
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c) for
|
|
// the DOCS request specifically: the real interceptor ran (all proxy headers
|
|
// present, identity=user, method+path+target as expected), 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()
|
|
seen := sc.findSeen(docsReqPath)
|
|
if seen == nil {
|
|
t.Fatalf("sidecar never received the docs request (path %q) — interceptor did not route it to AUTH_PROXY; saw %v",
|
|
docsReqPath, sidecarPaths(sc.seenAll()))
|
|
}
|
|
got := seen.req
|
|
if !seen.verifyRan {
|
|
t.Fatal("sidecar received the docs request but never reached HMAC verification (rejected earlier — see handler headers)")
|
|
}
|
|
if seen.verifyErr != nil {
|
|
t.Fatalf("HMAC verification failed on the fork's own signed docs request: %v", seen.verifyErr)
|
|
}
|
|
t.Logf("fork->sidecar docs headers: %v", got.headers)
|
|
|
|
// Target/method/path: prove we asserted on the real docs call to the real
|
|
// Feishu open platform, not an auxiliary request.
|
|
if got.method != http.MethodPost {
|
|
t.Errorf("docs request method = %q, want POST", got.method)
|
|
}
|
|
if targetHost, err := parseTargetHost(got.headers.Get(sidecar.HeaderProxyTarget)); err != nil {
|
|
t.Errorf("docs request %s invalid: %v", sidecar.HeaderProxyTarget, err)
|
|
} else if targetHost != wantProxyTargetHost {
|
|
t.Errorf("docs request proxy target host = %q, want %q", targetHost, wantProxyTargetHost)
|
|
}
|
|
|
|
// 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) for the DOCS request: the mock saw exactly the sidecar-injected
|
|
// synthetic token, never a sentinel or a real one — proving injection happened.
|
|
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
|
|
t.Helper()
|
|
got := up.sink.find(docsReqPath)
|
|
if got == nil {
|
|
t.Fatalf("mock upstream never received the forwarded docs request (path %q) — sidecar did not forward it after verification; saw %v",
|
|
docsReqPath, requestPaths(up.sink.all()))
|
|
}
|
|
t.Logf("sidecar->mock docs 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)
|
|
}
|
|
// The sidecar wire-protocol headers are between fork and sidecar only —
|
|
// the forward must strip every one of them. Token injection alone passing
|
|
// would still be a leak if signatures/timestamps/digests reached upstream.
|
|
for _, h := range []string{
|
|
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
|
|
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
|
|
sidecar.HeaderProxyAuthHeader,
|
|
} {
|
|
if v := got.headers.Get(h); v != "" {
|
|
t.Errorf("proxy protocol header %s leaked to upstream (want stripped): %q", h, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sidecarPaths / requestPaths render captured paths for failure messages.
|
|
func sidecarPaths(seen []sidecarSeen) []string {
|
|
paths := make([]string, len(seen))
|
|
for i, s := range seen {
|
|
paths[i] = s.req.method + " " + s.req.path
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func requestPaths(reqs []*capturedRequest) []string {
|
|
paths := make([]string, len(reqs))
|
|
for i, r := range reqs {
|
|
paths[i] = r.method + " " + r.path
|
|
}
|
|
return paths
|
|
}
|