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
321 lines
11 KiB
Go
321 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package whoami
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/identitydiag"
|
|
)
|
|
|
|
func TestResolveSource(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
changedAs bool
|
|
flagAs core.Identity
|
|
autoDetected bool
|
|
strictForced core.Identity
|
|
want string
|
|
}{
|
|
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
|
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
|
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
|
{"auto detected", false, "", true, "", "auto_detect"},
|
|
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
|
{"default_as", false, "", false, "", "default_as"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
|
if got != tt.want {
|
|
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildResult_UserValid(t *testing.T) {
|
|
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
|
diag := identitydiag.Result{
|
|
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
|
}
|
|
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
|
|
|
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
|
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
|
}
|
|
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
|
if !r.Available || r.TokenStatus != "ready" {
|
|
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
|
}
|
|
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
|
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
|
}
|
|
if r.Hint != "" {
|
|
t.Fatalf("hint = %q, want empty", r.Hint)
|
|
}
|
|
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
|
t.Fatalf("app context = %#v", r)
|
|
}
|
|
}
|
|
|
|
func TestBuildResult_UserMissingToken(t *testing.T) {
|
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
|
diag := identitydiag.Result{
|
|
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
|
}
|
|
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
|
|
|
if r.Available {
|
|
t.Fatalf("available = true, want false")
|
|
}
|
|
if r.TokenStatus != "missing" {
|
|
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
|
}
|
|
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
|
// stays correct for the external-provider path without whoami knowing about it.
|
|
if r.Hint != diag.User.Hint {
|
|
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
|
}
|
|
if r.DefaultAs != "auto" {
|
|
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
|
}
|
|
}
|
|
|
|
func TestBuildResult_BotReady(t *testing.T) {
|
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
|
diag := identitydiag.Result{
|
|
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
|
}
|
|
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
|
|
|
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
|
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
|
}
|
|
if !r.Available || r.TokenStatus != "ready" {
|
|
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
|
}
|
|
if r.OnBehalfOf != nil {
|
|
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
|
}
|
|
if r.Hint != "" {
|
|
t.Fatalf("hint = %q, want empty", r.Hint)
|
|
}
|
|
}
|
|
|
|
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
|
diag := identitydiag.Result{
|
|
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
|
}
|
|
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
|
|
|
if r.Available {
|
|
t.Fatalf("available = true, want false")
|
|
}
|
|
if r.TokenStatus != "not_configured" {
|
|
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
|
}
|
|
if r.Hint != diag.Bot.Hint {
|
|
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
|
}
|
|
}
|
|
|
|
func TestWhoami_BotJSON(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
|
|
var got whoamiResult
|
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
|
}
|
|
if got.Identity != "bot" {
|
|
t.Fatalf("identity = %q, want bot", got.Identity)
|
|
}
|
|
if !got.Available || got.TokenStatus != "ready" {
|
|
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
|
}
|
|
if got.Profile != "test-profile" {
|
|
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
|
}
|
|
if got.IdentitySource == "" {
|
|
t.Fatalf("identitySource empty")
|
|
}
|
|
if got.OnBehalfOf != nil {
|
|
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
|
}
|
|
}
|
|
|
|
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
|
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
|
t.Run("as="+bad, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{"--as", bad})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
|
}
|
|
// Lock in the typed validation contract: an unsupported identity must
|
|
// surface as a *errs.ValidationError on --as, not just any error.
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != "--as" {
|
|
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
wantErr := fmt.Errorf("boom")
|
|
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
|
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{"--json"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatalf("Execute() error = nil, want propagated config error")
|
|
}
|
|
// The f.Config() failure must propagate unchanged, not be masked by a later
|
|
// command-execution error.
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
|
// Bot-only account → strict mode bot. A real `--as user` call would be
|
|
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
|
// previewing a user identity the next call would refuse.
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
SupportedIdentities: 2, // bot only
|
|
})
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
|
}
|
|
}
|
|
|
|
type fakeExtProvider struct {
|
|
name string
|
|
account *extcred.Account
|
|
}
|
|
|
|
func (p *fakeExtProvider) Name() string { return p.name }
|
|
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
|
return p.account, nil
|
|
}
|
|
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
|
return nil, nil // no UAT served locally; whoami runs with verify=false
|
|
}
|
|
|
|
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
|
|
cred := credential.NewCredentialProvider(
|
|
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
|
|
nil, nil,
|
|
func() (*http.Client, error) { return nil, nil },
|
|
)
|
|
out := &bytes.Buffer{}
|
|
f := &cmdutil.Factory{
|
|
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
|
Credential: cred,
|
|
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
|
}
|
|
return f, out
|
|
}
|
|
|
|
// Regression for the external-provider blind spot: with credentials managed by
|
|
// an extension provider, a signed-in user must read as available, and an
|
|
// unavailable identity must not be told to "auth login" (which is blocked).
|
|
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
|
cfg := &core.CliConfig{
|
|
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
|
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
|
}
|
|
f, out := externalWhoamiFactory(cfg)
|
|
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
var got whoamiResult
|
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
|
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
|
}
|
|
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
|
t.Fatalf("got %#v, want user/available/ready", got)
|
|
}
|
|
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
|
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
|
}
|
|
if got.Hint != "" {
|
|
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
|
}
|
|
}
|
|
|
|
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
|
cfg := &core.CliConfig{
|
|
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
|
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
|
|
}
|
|
f, out := externalWhoamiFactory(cfg)
|
|
|
|
cmd := NewCmdWhoami(f)
|
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
var got whoamiResult
|
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
|
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
|
}
|
|
if got.Identity != "user" || got.Available {
|
|
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
|
}
|
|
if strings.Contains(got.Hint, "auth login") {
|
|
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
|
}
|
|
if !strings.Contains(got.Hint, "external") {
|
|
t.Fatalf("hint should explain external management: %q", got.Hint)
|
|
}
|
|
}
|