Skip to content

Commit 0a3c103

Browse files
committed
Merge branch 'feat/registry-auth-serve-mode' of github.com:stacklok/toolhive into feat/registry-auth-serve-mode
2 parents d9fde50 + 3b03164 commit 0a3c103

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

test/e2e/api_skills_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,86 @@ var _ = Describe("Skills API", Label("api", "api-clients", "skills", "e2e"), fun
759759
})
760760
})
761761

762+
Describe("Overwrite protection", func() {
763+
It("should reject install over existing skill without force", func() {
764+
skillName := "overwrite-noflag"
765+
766+
By("Installing the skill for the first time")
767+
resp := installSkill(apiServer, installSkillRequest{Name: skillName})
768+
defer resp.Body.Close()
769+
Expect(resp.StatusCode).To(Equal(http.StatusCreated))
770+
771+
By("Uninstalling via API so the DB record is gone but leave the concept of a conflict test")
772+
// Instead we test duplicate detection: installing the same name again
773+
// should return 409 Conflict (the DB record still exists).
774+
resp2 := installSkill(apiServer, installSkillRequest{Name: skillName})
775+
defer resp2.Body.Close()
776+
777+
By("Verifying response status is 409 Conflict")
778+
Expect(resp2.StatusCode).To(Equal(http.StatusConflict))
779+
})
780+
781+
It("should allow reinstall after uninstall", func() {
782+
skillName := "overwrite-reinstall"
783+
784+
By("Installing the skill")
785+
r1 := installSkill(apiServer, installSkillRequest{Name: skillName})
786+
defer r1.Body.Close()
787+
Expect(r1.StatusCode).To(Equal(http.StatusCreated))
788+
789+
By("Uninstalling the skill")
790+
r2 := uninstallSkill(apiServer, skillName)
791+
defer r2.Body.Close()
792+
Expect(r2.StatusCode).To(Equal(http.StatusNoContent))
793+
794+
By("Re-installing the skill (should succeed since DB record was removed)")
795+
r3 := installSkill(apiServer, installSkillRequest{Name: skillName})
796+
defer r3.Body.Close()
797+
Expect(r3.StatusCode).To(Equal(http.StatusCreated))
798+
})
799+
800+
It("should still reject duplicate DB record even with force flag", func() {
801+
skillName := "overwrite-force-dup"
802+
803+
By("Installing the skill for the first time")
804+
r1 := installSkill(apiServer, installSkillRequest{Name: skillName})
805+
defer r1.Body.Close()
806+
Expect(r1.StatusCode).To(Equal(http.StatusCreated))
807+
808+
By("Force-installing the same skill again (force is for filesystem conflicts, not DB duplicates)")
809+
r2 := installSkill(apiServer, installSkillRequest{Name: skillName, Force: true})
810+
defer r2.Body.Close()
811+
812+
By("Verifying response is still 409 Conflict (DB record exists)")
813+
Expect(r2.StatusCode).To(Equal(http.StatusConflict))
814+
})
815+
})
816+
817+
Describe("Build and validate lifecycle", func() {
818+
It("should build, then validate, the same skill directory", func() {
819+
skillName := "build-validate-lifecycle"
820+
821+
By("Creating a valid skill directory")
822+
skillDir := createTestSkillDir(skillName, "A skill for build-validate lifecycle")
823+
824+
By("Validating the skill")
825+
vResp := validateSkill(apiServer, skillDir)
826+
defer vResp.Body.Close()
827+
Expect(vResp.StatusCode).To(Equal(http.StatusOK))
828+
var vResult validationResultResponse
829+
Expect(json.NewDecoder(vResp.Body).Decode(&vResult)).To(Succeed())
830+
Expect(vResult.Valid).To(BeTrue())
831+
832+
By("Building the skill")
833+
bResp := buildSkill(apiServer, skillDir, "v0.1.0")
834+
defer bResp.Body.Close()
835+
Expect(bResp.StatusCode).To(Equal(http.StatusOK))
836+
var bResult buildResultResponse
837+
Expect(json.NewDecoder(bResp.Body).Decode(&bResult)).To(Succeed())
838+
Expect(bResult.Reference).ToNot(BeEmpty())
839+
})
840+
})
841+
762842
Describe("Full lifecycle integration", func() {
763843
It("should support install → list → info → uninstall → list → info", func() {
764844
skillName := "lifecycle-test"

test/e2e/cli_skills_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package e2e_test
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
. "github.com/onsi/ginkgo/v2"
14+
. "github.com/onsi/gomega"
15+
16+
"github.com/stacklok/toolhive/test/e2e"
17+
)
18+
19+
var _ = Describe("Skills CLI", Label("api", "cli", "skills", "e2e"), func() {
20+
var (
21+
config *e2e.ServerConfig
22+
apiServer *e2e.Server
23+
thvConfig *e2e.TestConfig
24+
)
25+
26+
BeforeEach(func() {
27+
config = e2e.NewServerConfig()
28+
apiServer = e2e.StartServer(config)
29+
thvConfig = e2e.NewTestConfig()
30+
})
31+
32+
// thvSkillCmd creates a THVCommand for `thv skill <args>` with
33+
// TOOLHIVE_API_URL pointing to the test server.
34+
thvSkillCmd := func(args ...string) *e2e.THVCommand {
35+
fullArgs := append([]string{"skill"}, args...)
36+
return e2e.NewTHVCommand(thvConfig, fullArgs...).
37+
WithEnv("TOOLHIVE_API_URL=" + apiServer.BaseURL())
38+
}
39+
40+
Describe("thv skill validate", func() {
41+
It("should succeed for a valid skill directory", func() {
42+
skillDir := createTestSkillDir("cli-valid-skill", "A valid skill for CLI testing")
43+
44+
stdout, _ := thvSkillCmd("validate", skillDir).ExpectSuccess()
45+
// Text output should not contain "Error:" lines for a valid skill
46+
Expect(stdout).ToNot(ContainSubstring("Error:"))
47+
})
48+
49+
It("should succeed with JSON output", func() {
50+
skillDir := createTestSkillDir("cli-valid-json", "A valid skill for JSON output")
51+
52+
stdout, _ := thvSkillCmd("validate", "--format", "json", skillDir).ExpectSuccess()
53+
54+
var result validationResultResponse
55+
Expect(json.Unmarshal([]byte(stdout), &result)).To(Succeed())
56+
Expect(result.Valid).To(BeTrue())
57+
})
58+
59+
It("should fail for an invalid skill directory", func() {
60+
emptyDir := GinkgoT().TempDir()
61+
62+
_, _, err := thvSkillCmd("validate", emptyDir).Run()
63+
Expect(err).To(HaveOccurred(), "validate should fail for directory without SKILL.md")
64+
})
65+
})
66+
67+
Describe("thv skill build", func() {
68+
It("should build a valid skill and print the reference", func() {
69+
skillDir := createTestSkillDir("cli-build-skill", "A skill for CLI build testing")
70+
71+
stdout, _ := thvSkillCmd("build", skillDir).ExpectSuccess()
72+
// The build command should output something (the reference)
73+
Expect(strings.TrimSpace(stdout)).ToNot(BeEmpty())
74+
})
75+
})
76+
77+
Describe("thv skill install and list", func() {
78+
It("should install a skill and list it", func() {
79+
skillName := fmt.Sprintf("cli-install-%d", GinkgoRandomSeed())
80+
81+
By("Installing the skill")
82+
thvSkillCmd("install", skillName).ExpectSuccess()
83+
84+
By("Listing skills in text format — should show the installed skill")
85+
stdout, _ := thvSkillCmd("list").ExpectSuccess()
86+
Expect(stdout).To(ContainSubstring(skillName))
87+
88+
By("Listing skills in JSON format")
89+
jsonOut, _ := thvSkillCmd("list", "--format", "json").ExpectSuccess()
90+
var skills []json.RawMessage
91+
Expect(json.Unmarshal([]byte(jsonOut), &skills)).To(Succeed())
92+
Expect(skills).ToNot(BeEmpty())
93+
})
94+
})
95+
96+
Describe("thv skill info", func() {
97+
It("should show info for an installed skill", func() {
98+
skillName := fmt.Sprintf("cli-info-%d", GinkgoRandomSeed())
99+
100+
By("Installing the skill")
101+
thvSkillCmd("install", skillName).ExpectSuccess()
102+
103+
By("Getting info in text format")
104+
stdout, _ := thvSkillCmd("info", skillName).ExpectSuccess()
105+
Expect(stdout).To(ContainSubstring(skillName))
106+
107+
By("Getting info in JSON format")
108+
jsonOut, _ := thvSkillCmd("info", "--format", "json", skillName).ExpectSuccess()
109+
Expect(jsonOut).To(ContainSubstring(skillName))
110+
})
111+
112+
It("should fail for a non-existent skill", func() {
113+
_, _, err := thvSkillCmd("info", "no-such-skill-xyz").Run()
114+
Expect(err).To(HaveOccurred())
115+
})
116+
})
117+
118+
Describe("thv skill uninstall", func() {
119+
It("should uninstall an installed skill", func() {
120+
skillName := fmt.Sprintf("cli-uninstall-%d", GinkgoRandomSeed())
121+
122+
By("Installing the skill")
123+
thvSkillCmd("install", skillName).ExpectSuccess()
124+
125+
By("Uninstalling the skill")
126+
thvSkillCmd("uninstall", skillName).ExpectSuccess()
127+
128+
By("Verifying the skill is no longer listed")
129+
stdout, _ := thvSkillCmd("list").ExpectSuccess()
130+
Expect(stdout).ToNot(ContainSubstring(skillName))
131+
})
132+
133+
It("should fail for a non-existent skill", func() {
134+
_, _, err := thvSkillCmd("uninstall", "no-such-skill-xyz").Run()
135+
Expect(err).To(HaveOccurred())
136+
})
137+
})
138+
139+
Describe("CLI full lifecycle", func() {
140+
It("should support validate → build → install → list → info → uninstall → list", func() {
141+
skillName := fmt.Sprintf("cli-lifecycle-%d", GinkgoRandomSeed())
142+
143+
By("Creating a valid skill directory")
144+
parentDir := GinkgoT().TempDir()
145+
skillDir := filepath.Join(parentDir, skillName)
146+
Expect(os.MkdirAll(skillDir, 0o755)).To(Succeed())
147+
148+
skillMD := fmt.Sprintf(`---
149+
name: %s
150+
description: Full lifecycle CLI test
151+
version: 1.0.0
152+
---
153+
154+
# %s
155+
156+
A test skill for the full CLI lifecycle.
157+
`, skillName, skillName)
158+
Expect(os.WriteFile(
159+
filepath.Join(skillDir, "SKILL.md"),
160+
[]byte(skillMD),
161+
0o644,
162+
)).To(Succeed())
163+
164+
By("Validating the skill")
165+
thvSkillCmd("validate", skillDir).ExpectSuccess()
166+
167+
By("Building the skill")
168+
thvSkillCmd("build", skillDir).ExpectSuccess()
169+
170+
By("Installing the skill by name (pending)")
171+
thvSkillCmd("install", skillName).ExpectSuccess()
172+
173+
By("Listing skills — should contain the skill")
174+
listOut, _ := thvSkillCmd("list").ExpectSuccess()
175+
Expect(listOut).To(ContainSubstring(skillName))
176+
177+
By("Getting skill info")
178+
infoOut, _ := thvSkillCmd("info", skillName).ExpectSuccess()
179+
Expect(infoOut).To(ContainSubstring(skillName))
180+
181+
By("Uninstalling the skill")
182+
thvSkillCmd("uninstall", skillName).ExpectSuccess()
183+
184+
By("Listing skills — should no longer contain the skill")
185+
listOut2, _ := thvSkillCmd("list").ExpectSuccess()
186+
Expect(listOut2).ToNot(ContainSubstring(skillName))
187+
})
188+
})
189+
})

0 commit comments

Comments
 (0)