Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions cmd/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"testing"
Expand All @@ -28,13 +30,31 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}

func officialSkillsIndexURLForTest(t *testing.T) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/.well-known/skills/index.json" {
t.Fatalf("path = %s, want /.well-known/skills/index.json", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`))
}))
t.Cleanup(server.Close)
return server.URL + "/.well-known/skills/index.json"
}

// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.OfficialSkillsIndexURL = officialSkillsIndexURLForTest(t)
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
Expand All @@ -49,6 +69,7 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.OfficialSkillsIndexURL = officialSkillsIndexURLForTest(t)
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
Expand All @@ -59,10 +80,14 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
case "-y skills add https://open.feishu.cn -s lark-calendar lark-mail -g -y":
// incremental install succeeds
case "-y skills add https://open.feishu.cn -g -y":
// full install succeeds
case "-y skills add larksuite/cli -g -y":
// fallback full install succeeds
default:
}
return r
Expand Down
79 changes: 72 additions & 7 deletions internal/selfupdate/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"regexp"
"strings"
"time"

Expand All @@ -24,6 +27,8 @@
// Tests that mutate execLookPath must not call t.Parallel().
var execLookPath = exec.LookPath

var officialSkillNamePattern = regexp.MustCompile(`^lark-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)

// InstallMethod describes how the CLI was installed.
type InstallMethod int

Expand All @@ -42,6 +47,13 @@
verifyTimeout = 10 * time.Second
)

const (
officialSkillsIndexDefaultURL = "https://open.feishu.cn/.well-known/skills/index.json"
officialSkillsIndexMaxBytes = 2 << 20
)

var officialSkillsIndexURL = officialSkillsIndexDefaultURL

// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
Expand Down Expand Up @@ -86,6 +98,7 @@
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
OfficialSkillsIndexURL string

// backupCreated is set to true by PrepareSelfReplace (Windows) when the
// running binary is successfully renamed to .old. Used by
Expand Down Expand Up @@ -154,10 +167,53 @@
}

func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
return u.fetchOfficialSkillsIndex()
}

func (u *Updater) fetchOfficialSkillsIndex() *NpmResult {
r := &NpmResult{}
indexURL := officialSkillsIndexURL
if u.OfficialSkillsIndexURL != "" {
indexURL = u.OfficialSkillsIndexURL

Check warning on line 177 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L177

Added line #L177 was not covered by tests
}

ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil)
if err != nil {
r.Err = fmt.Errorf("create official skills index request %s: %w", indexURL, err)
return r

Check warning on line 186 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L185-L186

Added lines #L185 - L186 were not covered by tests
}
req.Header.Set("Accept", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("official skills index fetch timed out after %s: %s", skillsUpdateTimeout, indexURL)
return r

Check warning on line 194 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L192-L194

Added lines #L192 - L194 were not covered by tests
}
r.Err = fmt.Errorf("fetch official skills index %s: %w", indexURL, err)
return r

Check warning on line 197 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L196-L197

Added lines #L196 - L197 were not covered by tests
}
defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("fetch official skills index %s: HTTP %s", indexURL, resp.Status)
return r
}

body, err := io.ReadAll(io.LimitReader(resp.Body, officialSkillsIndexMaxBytes+1))
if err != nil {
r.Err = fmt.Errorf("read official skills index %s: %w", indexURL, err)
return r

Check warning on line 209 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L208-L209

Added lines #L208 - L209 were not covered by tests
}
if len(body) > officialSkillsIndexMaxBytes {
r.Err = fmt.Errorf("official skills index %s exceeds %d bytes", indexURL, officialSkillsIndexMaxBytes)
return r
}

_, _ = r.Stdout.Write(body)
return r
}

Expand All @@ -166,13 +222,26 @@
}

func (u *Updater) InstallSkill(nameList []string) *NpmResult {
if err := validateOfficialSkillNames(nameList); err != nil {
return &NpmResult{Err: err}
}

r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
return r
}

func validateOfficialSkillNames(nameList []string) error {
for _, name := range nameList {
if !officialSkillNamePattern.MatchString(name) {
return fmt.Errorf("invalid official skill name %q", name)
}
}
return nil

Check warning on line 242 in internal/selfupdate/updater.go

View check run for this annotation

Codecov / codecov/patch

internal/selfupdate/updater.go#L242

Added line #L242 was not covered by tests
}

func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
Expand All @@ -185,10 +254,6 @@
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}

func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}

func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
Expand Down
106 changes: 81 additions & 25 deletions internal/selfupdate/updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package selfupdate

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -174,13 +176,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
run func(*Updater) *NpmResult
want string
}{
{
name: "list official primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
},
want: "-y skills add https://open.feishu.cn --list",
},
{
name: "list global",
run: func(u *Updater) *NpmResult {
Expand Down Expand Up @@ -225,29 +220,90 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}

func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
func TestListOfficialSkillsFetchesIndexJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/.well-known/skills/index.json" {
t.Fatalf("path = %s, want /.well-known/skills/index.json", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skills":[{"name":"lark-calendar"}]}`))
}))
defer server.Close()

oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
defer func() { officialSkillsIndexURL = oldURL }()

u := New()
result := u.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if got := result.Stdout.String(); !strings.Contains(got, `"lark-calendar"`) {
t.Fatalf("ListOfficialSkills() stdout = %q, want index JSON", got)
}
}

func TestListOfficialSkillsNon2xxFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusBadGateway)
}))
defer server.Close()

oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
defer func() { officialSkillsIndexURL = oldURL }()

u := New()
result := u.ListOfficialSkills()
if result.Err == nil {
t.Fatal("ListOfficialSkills() err = nil, want error")
}
if !strings.Contains(result.Err.Error(), "502") {
t.Fatalf("ListOfficialSkills() err = %v, want HTTP status", result.Err)
}
}

func TestListOfficialSkillsTooLargeFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("x", officialSkillsIndexMaxBytes+1)))
}))
defer server.Close()

oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
defer func() { officialSkillsIndexURL = oldURL }()

u := New()
result := u.ListOfficialSkills()
if result.Err == nil {
t.Fatal("ListOfficialSkills() err = nil, want error")
}
if !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkills() err = %v, want size limit error", result.Err)
}
}

func TestInstallSkillRejectsInvalidOfficialNames(t *testing.T) {
called := false
u := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
called = true
return &NpmResult{}
},
}

result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
result := u.InstallSkill([]string{"lark-calendar", "lark-calendar@evil"})
if result.Err == nil {
t.Fatal("InstallSkill() err = nil, want invalid name error")
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
if !strings.Contains(result.Err.Error(), "invalid official skill name") {
t.Fatalf("InstallSkill() err = %v, want invalid name error", result.Err)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
if called {
t.Fatal("InstallSkill() called skills command for invalid name")
}
}
Loading
Loading