mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4488da0b14
* feat(apps): add +user-id-convert shortcut for Miaoda↔Feishu ID conversion
Wrap the platform id_convert OpenAPI as a read-only shortcut that maps
Miaoda user_id ↔ Feishu open platform IDs (open_id / union_id / Feishu
user_id). It does one thing — conversion — with no local mapping table,
caching, permission pre-check, or direction guessing.
- --convert-type enum → server id_convert_type (10/11/20/21/40)
- --ids: csv / @file / stdin, 1-100 per call, not de-duped, input order
- reconstructs data.missed by diffing input positions against returned
source_ids (server silently drops unresolved IDs), keyed by 0-based index
- meta counters (total/hit_count/missed_count) via pointer fields on
output.Meta so an explicit missed_count: 0 survives omitempty
* test(apps): address review feedback on +user-id-convert
- reject empty --ids CSV entries (e.g. "a,,b") with a typed validation
error instead of silently dropping them, since a dropped entry shifts
every later result's 0-based index and breaks the position-keyed
items/missed contract; add an interior-empty-element test
- reuse common.GetSlice / common.GetString for response projection
(house convention) instead of local asSlice/asString helpers
- requireConvertValidation now asserts CategoryValidation +
SubtypeInvalidArgument via errs.ProblemOf, keeping ValidationError.Param
- table-drive TestResolveConvertType over all five directions so every
--convert-type → id_convert_type mapping (10/11/20/21/40) is protected
* fix(apps): split newline-delimited --ids for +user-id-convert @file/stdin
@file and - (stdin) input arrives verbatim from the framework as
one-ID-per-line text, but parseConvertIDs only split on commas, so such a
block was sent as a single malformed request ID. Treat a newline as
equivalent to a comma, tolerating a file's trailing newline while still
rejecting interior empty entries so position-keyed result indices stay
aligned. Add @file and stdin tests asserting the request body's ids are
split into discrete IDs.
* fix(apps): stringify numeric JSON IDs in +user-id-convert results
Responses decode with json.Number (client.ParseJSONResponse uses
dec.UseNumber()), so a server that emits source_id/target_id as bare
numbers — plausible for the numeric Miaoda user_id form — was silently
coerced to "" by buildConvertResult's strict string assertion: the
source_id got dropped (false not_found) and the target_id blanked
(false success).
Add common.GetStringLoose, which stringifies string/json.Number/int64/
float64 via literal text (large integer IDs keep full precision, never
routed through a lossy float64), and use it for both id reads. Cover it
with a package-level table test plus an end-to-end regression asserting a
numeric-JSON response yields intact, non-blank ids and no false miss.
Also exercise resolveConvertType's non-empty "not a valid direction"
branch directly, since the runner's enum gate preempts it in normal flow.
* test(common): tighten GetStringLoose numeric coverage
Add an int-branch case (was only covering int64) and swap the float64
fixture from 42 — which no formatter would render in exponent form — to
1e-7, whose fixed-point rendering "0.0000001" fails under the 'g' verb.
This turns the "no scientific notation" case into a real guard for the
'f' verb choice, per CodeRabbit review on c64cca39.
204 lines
6.6 KiB
Go
204 lines
6.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
|
||
// 6 基础 + 1 init + 3 publish + 1 env-pull
|
||
// - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list)
|
||
// - 3 env(list/set/delete)
|
||
// - 23 db(table-list/table-schema/sql/dev-init/data-import/data-export/sync create/list/get/enable/disable/update/delete/changelog-list/
|
||
// audit-status/audit-enable/audit-disable/audit-list/
|
||
// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get)
|
||
// - 7 file(list/get/sign/download/upload/delete/quota-get)
|
||
// - 3 git-credential
|
||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||
// - 3 cache(get/delete/clear)
|
||
// - 3 plugin(install/uninstall/list)
|
||
// - 6 automation(list/get/create/update/enable/disable)
|
||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)
|
||
// - 6 creative app member/permission settings
|
||
// - 7 db-sync(create/list/get/enable/disable/update/delete)
|
||
// - 1 user-id-convert = 96。
|
||
func TestAppsShortcuts_Returns96(t *testing.T) {
|
||
got := Shortcuts()
|
||
if len(got) != 96 {
|
||
t.Fatalf("Shortcuts() returned %d entries, want 96", len(got))
|
||
}
|
||
}
|
||
|
||
func TestAppsShortcuts_IncludesMemberCommandsWithExactSecurityMetadata(t *testing.T) {
|
||
want := map[string]struct {
|
||
risk string
|
||
scope string
|
||
}{
|
||
"+member-list": {risk: "read", scope: "spark:app:read"},
|
||
"+member-add": {risk: "high-risk-write", scope: "spark:app:write"},
|
||
"+member-update": {risk: "high-risk-write", scope: "spark:app:write"},
|
||
"+member-remove": {risk: "high-risk-write", scope: "spark:app:write"},
|
||
"+member-settings-get": {risk: "read", scope: "spark:app:read"},
|
||
"+member-settings-set": {risk: "high-risk-write", scope: "spark:app:write"},
|
||
}
|
||
|
||
for _, sc := range Shortcuts() {
|
||
expected, ok := want[sc.Command]
|
||
if !ok {
|
||
continue
|
||
}
|
||
delete(want, sc.Command)
|
||
if sc.Hidden {
|
||
t.Errorf("%s must be visible", sc.Command)
|
||
}
|
||
if sc.Risk != expected.risk {
|
||
t.Errorf("%s risk = %q, want %q", sc.Command, sc.Risk, expected.risk)
|
||
}
|
||
if len(sc.Scopes) != 1 || sc.Scopes[0] != expected.scope {
|
||
t.Errorf("%s scopes = %#v, want [%q]", sc.Command, sc.Scopes, expected.scope)
|
||
}
|
||
if len(sc.AuthTypes) != 1 || sc.AuthTypes[0] != "user" {
|
||
t.Errorf("%s auth types = %#v, want [user]", sc.Command, sc.AuthTypes)
|
||
}
|
||
}
|
||
|
||
for command := range want {
|
||
t.Errorf("Shortcuts() missing %s", command)
|
||
}
|
||
}
|
||
|
||
func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) {
|
||
for _, sc := range Shortcuts() {
|
||
switch sc.Command {
|
||
case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete":
|
||
t.Fatalf("Shortcuts() must not register %s", sc.Command)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) {
|
||
for _, sc := range Shortcuts() {
|
||
switch sc.Command {
|
||
case "+metric-query", "+analytics-query":
|
||
t.Fatalf("Shortcuts() must not register %s", sc.Command)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) {
|
||
want := map[string]bool{
|
||
"+env-list": false,
|
||
"+env-set": false,
|
||
"+env-delete": false,
|
||
}
|
||
for _, sc := range Shortcuts() {
|
||
if _, ok := want[sc.Command]; ok {
|
||
want[sc.Command] = true
|
||
if sc.Hidden {
|
||
t.Errorf("%s must be visible", sc.Command)
|
||
}
|
||
}
|
||
}
|
||
for cmd, found := range want {
|
||
if !found {
|
||
t.Errorf("Shortcuts() missing canonical %s", cmd)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确认 5 个 session 生命周期命令都已挂载。
|
||
func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
|
||
want := map[string]bool{
|
||
"+session-create": false,
|
||
"+session-list": false,
|
||
"+session-get": false,
|
||
"+session-stop": false,
|
||
"+chat": false,
|
||
}
|
||
for _, sc := range Shortcuts() {
|
||
if _, ok := want[sc.Command]; ok {
|
||
want[sc.Command] = true
|
||
}
|
||
}
|
||
for cmd, found := range want {
|
||
if !found {
|
||
t.Errorf("Shortcuts() missing %s", cmd)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
|
||
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
|
||
want := map[string]bool{
|
||
"+role-list": false,
|
||
"+role-get": false,
|
||
"+role-create": false,
|
||
"+role-update": false,
|
||
"+role-delete": false,
|
||
"+role-member-list": false,
|
||
"+role-member-add": false,
|
||
"+role-member-remove": false,
|
||
"+role-match-list": false,
|
||
}
|
||
for _, sc := range Shortcuts() {
|
||
if _, ok := want[sc.Command]; ok {
|
||
want[sc.Command] = true
|
||
if sc.Hidden {
|
||
t.Errorf("%s must be visible", sc.Command)
|
||
}
|
||
}
|
||
}
|
||
for cmd, found := range want {
|
||
if !found {
|
||
t.Errorf("Shortcuts() missing %s", cmd)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
|
||
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
|
||
for _, shortcut := range Shortcuts() {
|
||
if shortcut.Command == "git-credential-helper" {
|
||
t.Fatalf("git credential helper must be installed as a hidden apps command, not as a shortcut")
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes 确认 git credential remove 是本地清理、不带任何 scope。
|
||
func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) {
|
||
if len(AppsGitCredentialRemove.Scopes) != 0 {
|
||
t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes)
|
||
}
|
||
}
|
||
|
||
// TestAppsGitCredentialList_IsLocalReadWithoutScopes 确认 git credential list 是本地读取、不带任何 scope。
|
||
func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) {
|
||
if len(AppsGitCredentialList.Scopes) != 0 {
|
||
t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes)
|
||
}
|
||
}
|
||
|
||
// TestInstallOnApps_AddsHiddenGitCredentialHelper 验证 InstallOnApps 挂载一个隐藏、带 RunE 且独立于 shortcut 管线的 git-credential-helper 命令。
|
||
func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) {
|
||
parent := &cobra.Command{Use: "apps"}
|
||
InstallOnApps(parent, nil)
|
||
cmd, _, err := parent.Find([]string{"git-credential-helper"})
|
||
if err != nil {
|
||
t.Fatalf("find helper returned error: %v", err)
|
||
}
|
||
if cmd == nil || cmd.Name() != "git-credential-helper" {
|
||
t.Fatalf("helper command not installed: %#v", cmd)
|
||
}
|
||
if !cmd.Hidden {
|
||
t.Fatalf("git credential helper must be hidden")
|
||
}
|
||
if cmd.RunE == nil {
|
||
t.Fatalf("git credential helper must run outside the shortcut pipeline")
|
||
}
|
||
}
|