diff --git a/CLAUDE.md b/CLAUDE.md index e36b6be..4c62ef5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Each subsystem has different test infrastructure — see `docs/development.md` f - **UK English**: colour, organisation, centre, licence (noun), authorise, behaviour - **Tests**: testify assert/require, table-driven preferred, `_Good`/`_Bad`/`_Ugly` suffix naming - **Imports**: stdlib → `forge.lthn.ai/...` → third-party, each group separated by blank line -- **Errors**: `"package.Func: context: %w"` or `log.E("package.Func", "context", err)` — no bare `fmt.Errorf` +- **Errors**: `coreerr.E("package.Func", "context", err)` via `coreerr "forge.lthn.ai/core/go-log"` — no bare `fmt.Errorf` or `errors.New` - **Conventional commits**: `feat(forge):`, `fix(gitea):`, `test(collect):`, `docs(agentci):`, `refactor(collect):`, `chore:` - **Co-Author trailer**: `Co-Authored-By: Virgil ` - **Licence**: EUPL-1.2 diff --git a/agentci/security_test.go b/agentci/security_test.go new file mode 100644 index 0000000..6d0b68e --- /dev/null +++ b/agentci/security_test.go @@ -0,0 +1,122 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package agentci + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizePath_Good(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"simple", "simple"}, + {"with-dash", "with-dash"}, + {"with_underscore", "with_underscore"}, + {"with.dot", "with.dot"}, + {"CamelCase", "CamelCase"}, + {"123", "123"}, + {"path/to/file.txt", "file.txt"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := SanitizePath(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSanitizePath_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"spaces", "has space"}, + {"special chars", "file@name"}, + {"backtick", "file`name"}, + {"semicolon", "file;name"}, + {"pipe", "file|name"}, + {"ampersand", "file&name"}, + {"dollar", "file$name"}, + {"parent traversal base", ".."}, + {"root", "/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := SanitizePath(tt.input) + assert.Error(t, err) + }) + } +} + +func TestEscapeShellArg_Good(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"simple", "'simple'"}, + {"with spaces", "'with spaces'"}, + {"it's", "'it'\\''s'"}, + {"", "''"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, EscapeShellArg(tt.input)) + }) + } +} + +func TestSecureSSHCommand_Good(t *testing.T) { + cmd := SecureSSHCommand("host.example.com", "ls -la") + args := cmd.Args + + assert.Equal(t, "ssh", args[0]) + assert.Contains(t, args, "-o") + assert.Contains(t, args, "StrictHostKeyChecking=yes") + assert.Contains(t, args, "BatchMode=yes") + assert.Contains(t, args, "ConnectTimeout=10") + assert.Equal(t, "host.example.com", args[len(args)-2]) + assert.Equal(t, "ls -la", args[len(args)-1]) +} + +func TestMaskToken_Good(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"long token", "abcdefghijklmnop", "abcd****mnop"}, + {"exactly 8", "12345678", "1234****5678"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, MaskToken(tt.input)) + }) + } +} + +func TestMaskToken_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"short", "abc"}, + {"empty", ""}, + {"seven chars", "1234567"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, "*****", MaskToken(tt.input)) + }) + } +} diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go index d85f0a5..65e0440 100644 --- a/cmd/forge/cmd_forge.go +++ b/cmd/forge/cmd_forge.go @@ -14,11 +14,10 @@ package forge import ( "forge.lthn.ai/core/cli/pkg/cli" - "forge.lthn.ai/core/go-scm/locales" ) func init() { - cli.RegisterCommands(AddForgeCommands, locales.FS) + cli.RegisterCommands(AddForgeCommands) } // Style aliases from shared package. diff --git a/pkg/api/provider_handlers_test.go b/pkg/api/provider_handlers_test.go new file mode 100644 index 0000000..a4590d6 --- /dev/null +++ b/pkg/api/provider_handlers_test.go @@ -0,0 +1,207 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + goapi "forge.lthn.ai/core/api" + "forge.lthn.ai/core/go-scm/marketplace" + scmapi "forge.lthn.ai/core/go-scm/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// -- Marketplace: category filter --------------------------------------------- + +func TestScmProvider_ListMarketplace_Category_Good(t *testing.T) { + idx := &marketplace.Index{ + Version: 1, + Modules: []marketplace.Module{ + {Code: "analytics", Name: "Analytics", Category: "product"}, + {Code: "lint", Name: "Linter", Category: "tool"}, + }, + Categories: []string{"product", "tool"}, + } + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace?category=tool", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]marketplace.Module] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "lint", resp.Data[0].Code) +} + +// -- Marketplace: nil search results ------------------------------------------ + +func TestScmProvider_ListMarketplace_SearchNoResults_Good(t *testing.T) { + idx := &marketplace.Index{ + Version: 1, + Modules: []marketplace.Module{ + {Code: "analytics", Name: "Analytics", Category: "product"}, + }, + } + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace?q=nonexistent", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// -- GetMarketplaceItem: nil index -------------------------------------------- + +func TestScmProvider_GetMarketplaceItem_NilIndex_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/anything", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// -- Install: nil dependencies ------------------------------------------------ + +func TestScmProvider_InstallItem_NilDeps_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/marketplace/test/install", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestScmProvider_InstallItem_NotFound_Bad(t *testing.T) { + idx := &marketplace.Index{Version: 1} + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/marketplace/nonexistent/install", nil) + r.ServeHTTP(w, req) + + // installer is nil, so it returns unavailable first + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// -- Remove: nil installer ---------------------------------------------------- + +func TestScmProvider_RemoveItem_NilInstaller_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/api/v1/scm/marketplace/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// -- Update: nil installer ---------------------------------------------------- + +func TestScmProvider_UpdateInstalled_NilInstaller_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/installed/test/update", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// -- Manifest endpoints: no manifest on disk ---------------------------------- + +func TestScmProvider_GetManifest_NoFile_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/manifest", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestScmProvider_GetPermissions_NoFile_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/manifest/permissions", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// -- Verify: bad request ------------------------------------------------------ + +func TestScmProvider_VerifyManifest_NoBody_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/manifest/verify", nil) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// -- Sign: bad request -------------------------------------------------------- + +func TestScmProvider_SignManifest_NoBody_Bad(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/manifest/sign", nil) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// -- Describe: count routes --------------------------------------------------- + +func TestScmProvider_Describe_RouteCount_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + descs := p.Describe() + + // Verify every route from RegisterRoutes is described + expectedPaths := []string{ + "/marketplace", + "/marketplace/:code", + "/marketplace/:code/install", + "/manifest", + "/manifest/verify", + "/manifest/sign", + "/manifest/permissions", + "/installed", + "/installed/:code/update", + "/registry", + } + paths := make(map[string]bool) + for _, d := range descs { + paths[d.Path] = true + } + for _, ep := range expectedPaths { + assert.True(t, paths[ep], "missing description for %s", ep) + } +}