bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
266 lines
8.7 KiB
Go
266 lines
8.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build authsidecar
|
|
|
|
package sidecar
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/sidecar"
|
|
)
|
|
|
|
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
|
type failingBody struct {
|
|
err error
|
|
closed bool
|
|
readCall bool
|
|
}
|
|
|
|
func (b *failingBody) Read(p []byte) (int, error) {
|
|
b.readCall = true
|
|
return 0, b.err
|
|
}
|
|
|
|
func (b *failingBody) Close() error {
|
|
b.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
|
key := []byte("test-key-for-hmac-signing-32byte!")
|
|
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
|
|
|
body := []byte(`{"msg":"hello"}`)
|
|
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
|
req.Header.Set("X-Cli-Source", "lark-cli")
|
|
|
|
post := interceptor.PreRoundTrip(req)
|
|
|
|
if post != nil {
|
|
t.Error("expected nil post hook")
|
|
}
|
|
|
|
// URL should be rewritten to sidecar
|
|
if req.URL.Scheme != "http" {
|
|
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
|
}
|
|
if req.URL.Host != "127.0.0.1:16384" {
|
|
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
|
}
|
|
|
|
// Original target should be preserved
|
|
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
|
if target != "https://open.feishu.cn" {
|
|
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
|
}
|
|
|
|
// Identity should be user (from SentinelUAT)
|
|
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
|
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
|
}
|
|
|
|
// Authorization should be stripped
|
|
if auth := req.Header.Get("Authorization"); auth != "" {
|
|
t.Errorf("Authorization header should be stripped, got %q", auth)
|
|
}
|
|
|
|
// HMAC headers should be set
|
|
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
|
t.Error("signature header should be set")
|
|
}
|
|
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
|
t.Error("timestamp header should be set")
|
|
}
|
|
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
|
t.Error("body SHA256 header should be set")
|
|
}
|
|
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
|
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
|
}
|
|
|
|
// Non-proxy headers should be preserved
|
|
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
|
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
|
}
|
|
|
|
// Body should still be readable
|
|
readBody, _ := io.ReadAll(req.Body)
|
|
if !bytes.Equal(readBody, body) {
|
|
t.Errorf("body should be preserved after PreRoundTrip")
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_BotIdentity(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
|
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
|
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
|
req, _ := http.NewRequest("GET", origURL, nil)
|
|
req.Header.Set("Authorization", "Bearer some-real-token")
|
|
|
|
post := interceptor.PreRoundTrip(req)
|
|
|
|
// Should NOT be rewritten — no sentinel token
|
|
if post != nil {
|
|
t.Error("expected nil post hook for pass-through")
|
|
}
|
|
if req.URL.String() != origURL {
|
|
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
|
}
|
|
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
|
t.Error("proxy target header should not be set for pass-through")
|
|
}
|
|
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
|
t.Error("Authorization should be preserved for pass-through")
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
origURL := "https://cdn.feishu.cn/download/file"
|
|
req, _ := http.NewRequest("GET", origURL, nil)
|
|
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
// No Authorization header at all — should pass through
|
|
if req.URL.String() != origURL {
|
|
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_MCP_UAT(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
|
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
|
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
// Should be intercepted and rewritten
|
|
if req.URL.Host != "127.0.0.1:16384" {
|
|
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
|
}
|
|
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
|
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
|
}
|
|
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
|
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
|
}
|
|
// MCP sentinel should be stripped
|
|
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
|
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_MCP_TAT(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
|
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
|
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
|
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
|
}
|
|
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
|
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
|
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
|
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
|
}
|
|
}
|
|
|
|
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
|
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
|
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
|
// the sidecar side) and releases the original body.
|
|
func TestInterceptor_BodyReadError(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
|
body := &failingBody{err: errors.New("disk gremlin")}
|
|
|
|
req, _ := http.NewRequest("POST", origURL, body)
|
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
|
|
|
post := interceptor.PreRoundTrip(req)
|
|
|
|
if post != nil {
|
|
t.Error("expected nil post hook on body read failure")
|
|
}
|
|
|
|
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
|
if !body.readCall {
|
|
t.Error("expected ReadAll to have attempted reading from the body")
|
|
}
|
|
if !body.closed {
|
|
t.Error("expected original body to be Close()'d after read failure")
|
|
}
|
|
|
|
// URL must NOT be rewritten — request should fall through to the next
|
|
// layer (credential) which can surface a meaningful error.
|
|
if req.URL.String() != origURL {
|
|
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
|
}
|
|
|
|
// No proxy/HMAC headers should leak onto the request.
|
|
for _, h := range []string{
|
|
sidecar.HeaderProxyVersion,
|
|
sidecar.HeaderProxyTarget,
|
|
sidecar.HeaderProxySignature,
|
|
sidecar.HeaderProxyTimestamp,
|
|
sidecar.HeaderBodySHA256,
|
|
sidecar.HeaderProxyIdentity,
|
|
sidecar.HeaderProxyAuthHeader,
|
|
} {
|
|
if v := req.Header.Get(h); v != "" {
|
|
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInterceptor_EmptyBody(t *testing.T) {
|
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
|
|
|
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
|
interceptor.PreRoundTrip(req)
|
|
|
|
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
|
expectedEmpty := sidecar.BodySHA256(nil)
|
|
if sha != expectedEmpty {
|
|
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
|
}
|
|
}
|