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
406 lines
16 KiB
Go
406 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/smartystreets/goconvey/convey"
|
|
)
|
|
|
|
// jsonResponse builds a canned registration response (transport fakes reuse
|
|
// roundTripFunc from device_flow_test.go).
|
|
func jsonResponse(body string) *http.Response {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}
|
|
}
|
|
|
|
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
|
|
func Test_BuildVerificationURL(t *testing.T) {
|
|
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
|
|
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
|
|
convey.Convey("should add ? separator", t, func() {
|
|
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
|
|
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
|
|
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
|
|
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
|
|
})
|
|
})
|
|
|
|
t.Run("URL已含问号则添加&分隔符", func(t *testing.T) {
|
|
result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0")
|
|
convey.Convey("should add & separator", t, func() {
|
|
convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0")
|
|
convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0")
|
|
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
|
|
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestAppRegistrationEndpoint(t *testing.T) {
|
|
cases := []struct {
|
|
brand core.LarkBrand
|
|
want string
|
|
}{
|
|
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
|
|
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
|
|
}
|
|
for _, c := range cases {
|
|
if got := appRegistrationEndpoint(c.brand); got != c.want {
|
|
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
|
|
cases := []struct {
|
|
brand core.LarkBrand
|
|
verificationHost string
|
|
}{
|
|
{core.BrandFeishu, "open.feishu.cn"},
|
|
{core.BrandLark, "open.larksuite.com"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(string(c.brand), func(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
|
t.Errorf("begin host = %q, want bootstrap host %q", got, want)
|
|
}
|
|
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
|
|
})}
|
|
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
|
|
}
|
|
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
|
|
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Full Lark routing contract: Lark selects the Lark verification page, while
|
|
// registration bootstraps on Feishu and switches only after the tenant signal.
|
|
// The Lark credential response omits user_info, so the effective domain must
|
|
// still determine the saved brand.
|
|
func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
|
|
var calls []string
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("parse form: %v", err)
|
|
}
|
|
action := r.Form.Get("action")
|
|
calls = append(calls, action+"@"+r.URL.Host)
|
|
if action == "begin" {
|
|
return jsonResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":60,"interval":0}`), nil
|
|
}
|
|
switch r.URL.Host {
|
|
case "accounts.feishu.cn":
|
|
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
|
case "accounts.larksuite.com":
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
|
}
|
|
t.Errorf("unexpected host polled: %s", r.URL.Host)
|
|
return jsonResponse(`{}`), nil
|
|
})}
|
|
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RequestAppRegistration error = %v", err)
|
|
}
|
|
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
|
|
t.Errorf("verification URL = %q, want %q", got, want)
|
|
}
|
|
|
|
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
|
}
|
|
if finalBrand != core.BrandLark {
|
|
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
|
|
}
|
|
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
|
|
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
|
|
}
|
|
want := []string{"begin@accounts.feishu.cn", "poll@accounts.feishu.cn", "poll@accounts.larksuite.com"}
|
|
if len(calls) != len(want) {
|
|
t.Fatalf("calls = %v, want %v", calls, want)
|
|
}
|
|
for i := range want {
|
|
if calls[i] != want[i] {
|
|
t.Errorf("calls = %v, want %v", calls, want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plain path: the bootstrap domain can return complete Feishu credentials in
|
|
// one poll, even when user_info is absent.
|
|
func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
|
|
polls := 0
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
polls++
|
|
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
|
t.Errorf("poll host = %q, want %q", got, want)
|
|
}
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
|
|
|
_, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
|
}
|
|
if finalBrand != core.BrandFeishu {
|
|
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
|
|
}
|
|
if polls != 1 {
|
|
t.Errorf("polls = %d, want 1", polls)
|
|
}
|
|
}
|
|
|
|
// The discovery deadline must cancel in-flight requests: the fake transport
|
|
// hangs until the request context is done.
|
|
func TestRegisterAppWithDiscovery_DeadlineBoundsInFlightRequests(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
<-r.Context().Done()
|
|
return nil, r.Context().Err()
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 1}
|
|
|
|
start := time.Now()
|
|
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err == nil {
|
|
t.Fatal("expected timeout error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "timed out") {
|
|
t.Errorf("error = %v, want a timed-out terminal reason", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
|
t.Errorf("discovery not bounded by its deadline: took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
// Empty payloads and incomplete same-brand responses are not terminal.
|
|
func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
|
|
responses := []string{
|
|
`{}`,
|
|
`{"client_id":"cli_x","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
|
`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
|
}
|
|
polls := 0
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
body := responses[polls]
|
|
polls++
|
|
return jsonResponse(body), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
|
|
|
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
|
}
|
|
if polls != 3 {
|
|
t.Errorf("polls = %d, want 3", polls)
|
|
}
|
|
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
|
|
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
|
|
}
|
|
}
|
|
|
|
// Neither the first poll nor the cross-brand switch waits out the interval
|
|
// (a 5s interval would blow the elapsed bound).
|
|
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
|
|
var polledHosts []string
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
polledHosts = append(polledHosts, r.URL.Host)
|
|
if r.URL.Host == "accounts.feishu.cn" {
|
|
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
|
}
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 5, ExpiresIn: 60}
|
|
|
|
start := time.Now()
|
|
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
|
|
}
|
|
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
|
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
|
}
|
|
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
|
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
|
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
|
}
|
|
}
|
|
|
|
// Denial and expiry map to sentinels; cancellation preserves its cause.
|
|
func TestRegisterAppWithDiscovery_TerminalSentinels(t *testing.T) {
|
|
deny := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
return jsonResponse(`{"error":"access_denied"}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
|
_, _, err := RegisterAppWithDiscovery(context.Background(), deny, resp, io.Discard)
|
|
if !errors.Is(err, ErrRegistrationDenied) {
|
|
t.Errorf("denied err = %v, want ErrRegistrationDenied", err)
|
|
}
|
|
|
|
expired := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
return jsonResponse(`{"error":"expired_token"}`), nil
|
|
})}
|
|
_, _, err = RegisterAppWithDiscovery(context.Background(), expired, resp, io.Discard)
|
|
if !errors.Is(err, ErrRegistrationExpired) {
|
|
t.Errorf("expired err = %v, want ErrRegistrationExpired", err)
|
|
}
|
|
|
|
cancelledCtx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, _, err = RegisterAppWithDiscovery(cancelledCtx, deny, resp, io.Discard)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("cancelled err = %v, want a context.Canceled cause", err)
|
|
}
|
|
}
|
|
|
|
// Begin parsing: expire_in (legacy expires_in fallback), normalization, and
|
|
// required device_code.
|
|
func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
|
|
serve := func(body string) *http.Client {
|
|
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
return jsonResponse(body), nil
|
|
})}
|
|
}
|
|
|
|
resp, err := RequestAppRegistration(context.Background(),
|
|
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("begin error = %v", err)
|
|
}
|
|
if resp.ExpiresIn != 60 || resp.Interval != 3 {
|
|
t.Errorf("parsed (expire=%d, interval=%d), want (60, 3)", resp.ExpiresIn, resp.Interval)
|
|
}
|
|
|
|
resp, err = RequestAppRegistration(context.Background(),
|
|
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("legacy begin error = %v", err)
|
|
}
|
|
if resp.ExpiresIn != 45 || resp.Interval != 5 {
|
|
t.Errorf("legacy parsed (expire=%d, interval=%d), want (45, 5 — normalized default)", resp.ExpiresIn, resp.Interval)
|
|
}
|
|
|
|
resp, err = RequestAppRegistration(context.Background(),
|
|
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("defaults begin error = %v", err)
|
|
}
|
|
if resp.ExpiresIn != 600 || resp.Interval != 5 {
|
|
t.Errorf("defaults parsed (expire=%d, interval=%d), want (600, 5)", resp.ExpiresIn, resp.Interval)
|
|
}
|
|
|
|
if _, err := RequestAppRegistration(context.Background(),
|
|
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
|
|
t.Error("missing device_code: expected error, got nil")
|
|
}
|
|
}
|
|
|
|
// A tenant signal arriving alongside authorization_pending must still switch
|
|
// the polled domain (the official SDK checks the signal before the error).
|
|
func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) {
|
|
var polledHosts []string
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
polledHosts = append(polledHosts, r.URL.Host)
|
|
if r.URL.Host == "accounts.feishu.cn" {
|
|
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
|
}
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
|
|
|
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
|
}
|
|
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
|
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
|
}
|
|
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
|
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
|
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
|
}
|
|
}
|
|
|
|
// Polling has no attempt cap: only the expiry budget terminates the loop.
|
|
func TestRegisterAppWithDiscovery_NoAttemptCap(t *testing.T) {
|
|
polls := 0
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
polls++
|
|
if polls <= 250 {
|
|
return jsonResponse(`{"error":"authorization_pending"}`), nil
|
|
}
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 30}
|
|
|
|
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil (no attempts cap)", err)
|
|
}
|
|
if polls != 251 || result.ClientSecret != "test-secret" {
|
|
t.Errorf("polls = %d (want 251), secret = %q", polls, result.ClientSecret)
|
|
}
|
|
}
|
|
|
|
// A final tenant report contradicting the issuing domain is a protocol
|
|
// violation, not a brand override: the saved brand must never diverge from
|
|
// the domain that issued the credentials.
|
|
func TestRegisterAppWithDiscovery_ContradictoryFinalBrandFails(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Host == "accounts.feishu.cn" {
|
|
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
|
}
|
|
// The lark domain issues credentials but reports a feishu tenant.
|
|
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"tenant_brand":"feishu"}}`), nil
|
|
})}
|
|
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
|
|
|
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
|
if err == nil || !strings.Contains(err.Error(), "contradictory tenant brand") {
|
|
t.Errorf("err = %v, want contradictory-tenant-brand protocol error", err)
|
|
}
|
|
}
|
|
|
|
// A cancelled body read during begin must keep its context cause so the
|
|
// command layer classifies it as a cancellation, not an API failure.
|
|
func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(&errReader{err: context.Canceled}),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("err = %v, want a context.Canceled cause", err)
|
|
}
|
|
}
|
|
|
|
type errReader struct{ err error }
|
|
|
|
func (r *errReader) Read([]byte) (int, error) { return 0, r.err }
|