Files
larksuite-cli/shortcuts/apps/plugin_install_test.go
2026-06-30 11:50:13 +08:00

182 lines
5.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestPluginInstall_SinglePlugin(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{})
chdirTest(t, dir)
factory, stdout, reg := newAppsExecuteFactory(t)
// Mock batch_query API (new protocol: plugin_keys array, response data.items flat list)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/plugin/versions/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"key": "@test/my-plugin",
"version": "1.0.0",
"download_approach": "inner",
"status": "active",
},
},
},
},
})
// Mock download API (POST with JSON body, returns binary tgz)
tgzData := buildTestTGZ(t, map[string]string{
"manifest.json": `{"actions":[]}`,
"package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/plugin/versions/download_package",
RawBody: tgzData,
ContentType: "application/octet-stream",
})
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify file extracted
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
t.Fatalf("manifest.json not extracted: %v", err)
}
// Verify package.json updated
pkg, _ := pluginReadPackageJSON(dir)
ap := pluginGetActionPlugins(pkg)
if v := ap["@test/my-plugin"]; v != "1.0.0" {
t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v)
}
// Verify output
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
if data["status"] != "installed" {
t.Errorf("status = %v, want installed", data["status"])
}
}
func TestPluginInstall_AlreadyInstalled(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
// Create an existing installed plugin with package.json containing version
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
if data["status"] != "already_installed" {
t.Errorf("status = %v, want already_installed", data["status"])
}
}
// --- tgz helpers ---
func TestPluginExtractTGZ(t *testing.T) {
tgzData := buildTestTGZ(t, map[string]string{
"manifest.json": `{"actions":[]}`,
"README.md": "# Hello",
})
destDir := t.TempDir()
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
t.Fatalf("extract error: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
if err != nil {
t.Fatalf("manifest.json not extracted: %v", err)
}
if string(data) != `{"actions":[]}` {
t.Errorf("manifest.json content = %q", string(data))
}
}
func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
tw.WriteHeader(&tar.Header{
Name: "package/../../../etc/passwd",
Size: 5,
Mode: 0o644,
Typeflag: tar.TypeReg,
})
tw.Write([]byte("evil!"))
tw.Close()
gz.Close()
destDir := t.TempDir()
if err := pluginExtractTGZ(&buf, destDir); err != nil {
t.Fatalf("extract should not error, but skip bad entries: %v", err)
}
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
t.Error("path traversal should have been blocked")
}
}
// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix.
func buildTestTGZ(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, content := range files {
tw.WriteHeader(&tar.Header{
Name: "package/" + name,
Size: int64(len(content)),
Mode: 0o644,
Typeflag: tar.TypeReg,
})
tw.Write([]byte(content))
}
tw.Close()
gz.Close()
return buf.Bytes()
}