From 5a9c96e8a995b08a69307e65e382209f77802b04 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 14 Jun 2026 22:11:42 +0530 Subject: [PATCH 1/3] RTECO-1411 - Implement integration tests for Agent Plugins --- .github/workflows/agentPluginsTests.yml | 53 ++ agent_plugins_test.go | 559 ++++++++++++++++++ main_test.go | 6 + testdata/agent_plugins/test-plugin/README.md | 1 + .../agent_plugins/test-plugin/plugin.json | 5 + ...agent_plugins_local_repository_config.json | 5 + utils/tests/consts.go | 3 + utils/tests/utils.go | 13 +- 8 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/agentPluginsTests.yml create mode 100644 agent_plugins_test.go create mode 100644 testdata/agent_plugins/test-plugin/README.md create mode 100644 testdata/agent_plugins/test-plugin/plugin.json create mode 100644 testdata/agent_plugins_local_repository_config.json diff --git a/.github/workflows/agentPluginsTests.yml b/.github/workflows/agentPluginsTests.yml new file mode 100644 index 000000000..d4f204fe9 --- /dev/null +++ b/.github/workflows/agentPluginsTests.yml @@ -0,0 +1,53 @@ +name: Agent Plugins Tests +on: + workflow_dispatch: + push: + branches: + - "master" + # Triggers the workflow on PRs to master branch only. + pull_request_target: + types: [labeled] + branches: + - "master" + +# Ensures that only the latest commit is running for each PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Agent-Plugins-Tests: + name: agent-plugins ${{ matrix.os.name }} + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'safe to test') + strategy: + fail-fast: false + matrix: + os: + - name: ubuntu + version: 24.04 + - name: windows + version: 2022 + runs-on: ${{ matrix.os.name }}-${{ matrix.os.version }} + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Setup FastCI + uses: jfrog-fastci/fastci@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + fastci_otel_token: ${{ secrets.FASTCI_TOKEN }} + + - name: Setup Go with cache + uses: jfrog/.github/actions/install-go-with-cache@main + + - name: Install local Artifactory + uses: jfrog/.github/actions/install-local-artifactory@main + with: + RTLIC: ${{ secrets.RTLIC }} + RT_CONNECTION_TIMEOUT_SECONDS: ${{ env.RT_CONNECTION_TIMEOUT_SECONDS || '1200' }} + + - name: Run agent plugins tests + run: go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.agentPlugins diff --git a/agent_plugins_test.go b/agent_plugins_test.go new file mode 100644 index 000000000..8c8d3b9f3 --- /dev/null +++ b/agent_plugins_test.go @@ -0,0 +1,559 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + biutils "github.com/jfrog/build-info-go/utils" + artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + coreBuild "github.com/jfrog/jfrog-cli-core/v2/common/build" + coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/jfrog-cli/inttestutils" + "github.com/jfrog/jfrog-cli/utils/tests" +) + +// --------------------------------------------------------------------------- +// Init / cleanup +// --------------------------------------------------------------------------- + +func InitAgentPluginsTests() { + initArtifactoryCli() + cleanUpOldRepositories() + tests.AddTimestampToGlobalVars() + createRequiredRepos() +} + +func CleanAgentPluginsTests() { + deleteCreatedRepos() +} + +func initAgentPluginsTest(t *testing.T) { + if !*tests.TestAgentPlugins { + t.Skip("Skipping agent plugins tests. To run add '--test.agentPlugins'.") + } + createJfrogHomeConfig(t, false) + require.True(t, isRepoExist(tests.AgentPluginsLocalRepo), "agent plugins local repo does not exist: "+tests.AgentPluginsLocalRepo) +} + +func cleanAgentPluginsTest() { + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, tests.AgentPluginsBuildName, artHttpDetails) + _ = coreBuild.RemoveBuildDir(tests.AgentPluginsBuildName, "1", "") + tests.CleanFileSystem() +} + +// runAgentPluginsCmd executes `jf agent plugins `. +func runAgentPluginsCmd(t *testing.T, args ...string) error { + t.Helper() + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + return jfrogCli.Exec(append([]string{"agent", "plugins"}, args...)...) +} + +// createTestPlugin copies the test-plugin fixture to a fresh temp dir and patches +// plugin.json with the given slug and version so tests don't conflict. +func createTestPlugin(t *testing.T, slug, version string) string { + t.Helper() + pluginSrc := filepath.Join(filepath.FromSlash(tests.GetTestResourcesPath()), "agent_plugins", "test-plugin") + pluginPath, cleanup := coretests.CreateTempDirWithCallbackAndAssert(t) + t.Cleanup(cleanup) + + assert.NoError(t, biutils.CopyDir(pluginSrc, pluginPath, true, nil)) + + manifest := map[string]string{ + "name": slug, + "version": version, + "description": "Integration test plugin", + } + data, err := json.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(pluginPath, "plugin.json"), data, 0644)) // #nosec G306 -- test fixture + return pluginPath +} + +// assertPluginExists verifies the zip for slug/version is present in the local repo. +func assertPluginExists(t *testing.T, slug, version string) { + t.Helper() + sm, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) + require.NoError(t, err) + _, err = sm.GetItemProps(pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version)) + require.NoError(t, err, "artifact should exist: %s v%s", slug, version) +} + +// assertPluginAbsent verifies the zip for slug/version is gone from the local repo. +func assertPluginAbsent(t *testing.T, slug, version string) { + t.Helper() + sm, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) + require.NoError(t, err) + _, err = sm.GetItemProps(pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version)) + assert.Error(t, err, "artifact should not exist: %s v%s", slug, version) +} + +// pluginArtifactPath returns the Artifactory path for a published plugin zip: +// ///-.zip +func pluginArtifactPath(repo, slug, version string) string { + return repo + "/" + slug + "/" + version + "/" + slug + "-" + version + ".zip" +} + +// --------------------------------------------------------------------------- +// P0 — Publish +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublish verifies that publishing a plugin directory uploads +// the zip to the correct path in the agentplugins local repository. +// Covers scenarios #9 and #74. +func TestAgentPluginsPublish(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "publish-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + assertPluginExists(t, slug, version) +} + +// TestAgentPluginsVersionCollisionCI verifies that publishing the same version +// twice in CI/non-interactive mode fails with a clear "already exists" error. +// Covers scenario #19. +func TestAgentPluginsVersionCollisionCI(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "collision-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + // Force non-interactive mode so the collision check fails immediately. + t.Setenv("CI", "true") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ) + require.Error(t, err, "second publish of the same version in CI mode should fail") + assert.Contains(t, strings.ToLower(err.Error()), "already exists", + "error should mention 'already exists'") +} + +// TestAgentPluginsPublishWithVersion verifies that --version overrides the +// manifest version on publish. +// Covers scenario #11. +func TestAgentPluginsPublishWithVersion(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "version-override-plugin" + overrideVersion := "2.0.0" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--version="+overrideVersion, + )) + + assertPluginExists(t, slug, overrideVersion) +} + +// TestAgentPluginsPublishMissingPluginJson verifies that publishing a directory +// without plugin.json returns a clear error. +// Covers scenario #14. +func TestAgentPluginsPublishMissingPluginJson(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + emptyDir := t.TempDir() + err := runAgentPluginsCmd(t, + "publish", emptyDir, + "--repo="+tests.AgentPluginsLocalRepo, + ) + assert.Error(t, err, "publish of directory without plugin.json should fail") +} + +// TestAgentPluginsPublishToNonExistentRepo verifies that publishing to a +// nonexistent repository returns a clear error. +// Covers scenario #23. +func TestAgentPluginsPublishToNonExistentRepo(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + pluginPath := createTestPlugin(t, "invalid-repo-plugin", "1.0.0") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo=nonexistent-agent-plugins-repo-xyz", + ) + assert.Error(t, err, "publish to nonexistent repo should fail") +} + +// --------------------------------------------------------------------------- +// P0 — Install +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallLatest verifies that installing a plugin without +// --version picks up the latest published version and places files at +// //. Uses --path to bypass harness resolution. +// Covers scenarios #25 and #33. +func TestAgentPluginsInstallLatest(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "install-latest-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installDir := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + // Plugin files should be at // + assert.FileExists(t, filepath.Join(installDir, slug, "plugin.json"), + "plugin.json should exist after install") +} + +// TestAgentPluginsInstallSpecificVersion verifies that --version installs the +// requested version rather than latest. +// Covers scenario #32. +func TestAgentPluginsInstallSpecificVersion(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "install-version-plugin" + + // Publish two versions. + v1Path := createTestPlugin(t, slug, "1.0.0") + require.NoError(t, runAgentPluginsCmd(t, "publish", v1Path, "--repo="+tests.AgentPluginsLocalRepo)) + v2Path := createTestPlugin(t, slug, "2.0.0") + require.NoError(t, runAgentPluginsCmd(t, "publish", v2Path, "--repo="+tests.AgentPluginsLocalRepo)) + + installDir := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--version=1.0.0", + )) + + installedManifest := filepath.Join(installDir, slug, "plugin.json") + require.FileExists(t, installedManifest) + data, err := os.ReadFile(installedManifest) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]string + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, "1.0.0", manifest["version"], "installed version should be 1.0.0, not latest 2.0.0") +} + +// TestAgentPluginsInstallNotFound verifies that installing an unknown slug +// returns a clear not-found error. +// Covers scenario #34. +func TestAgentPluginsInstallNotFound(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + installDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", "nonexistent-slug-xyzzy", + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + ) + assert.Error(t, err, "installing an unknown slug should fail with a not-found error") +} + +// --------------------------------------------------------------------------- +// P0 — Delete +// --------------------------------------------------------------------------- + +// TestAgentPluginsDelete verifies that deleting a specific version removes +// the version folder from Artifactory. --version is always required by the command. +// Covers scenarios #48 and #49. +func TestAgentPluginsDelete(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "delete-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + assertPluginExists(t, slug, version) + + assert.NoError(t, runAgentPluginsCmd(t, + "delete", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--version="+version, + )) + assertPluginAbsent(t, slug, version) +} + +// TestAgentPluginsDeleteDryRun verifies that --dry-run does not remove the +// artifact from Artifactory. +// Covers scenario #50. +func TestAgentPluginsDeleteDryRun(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "delete-dryrun-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "delete", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--version="+version, + "--dry-run", + )) + + assertPluginExists(t, slug, version) +} + +// --------------------------------------------------------------------------- +// P0 — Search +// --------------------------------------------------------------------------- + +// TestAgentPluginsSearch verifies that `jf agent plugins search ` +// returns matches by the agentplugins.name property without error. +// Covers scenario #56. +func TestAgentPluginsSearch(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "search-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "search", slug, + "--repo="+tests.AgentPluginsLocalRepo, + ), "search should succeed after publish") +} + +// TestAgentPluginsSearchNoMatches verifies that searching with a query that +// matches nothing returns an empty result — not an error. +// Covers scenario #59. +func TestAgentPluginsSearchNoMatches(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + assert.NoError(t, runAgentPluginsCmd(t, + "search", "nonexistent-plugin-xyzzy-abc123", + "--repo="+tests.AgentPluginsLocalRepo, + ), "search with no matches should return empty result, not an error") +} + +// --------------------------------------------------------------------------- +// P0 — Checksum integrity +// --------------------------------------------------------------------------- + +// TestAgentPluginsChecksumIntegrity verifies that after publish the artifact +// in build info has a non-empty, non-"untrusted" SHA256 checksum, confirming +// Artifactory computed the checksum correctly on upload. +// Covers scenario #69. +func TestAgentPluginsChecksumIntegrity(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "checksum-plugin" + version := "1.0.0" + buildNumber := "1" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + )) + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.AgentPluginsBuildName, buildNumber) + require.NoError(t, err, "GetBuildInfo failed") + require.True(t, found, "build info not found — was jf rt bp successful?") + require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1) + require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Artifacts, + "expected at least one artifact in build info") + + for _, a := range publishedBuildInfo.BuildInfo.Modules[0].Artifacts { + assert.NotEmpty(t, a.Sha256, "artifact %s: sha256 must not be empty", a.Name) + assert.NotEqual(t, "untrusted", strings.ToLower(a.Sha256), + "artifact %s: sha256 must not be 'untrusted'", a.Name) + } +} + +// --------------------------------------------------------------------------- +// P0 — Round-trip (publish → install) +// --------------------------------------------------------------------------- + +// TestAgentPluginsRoundTrip publishes a plugin then installs it and verifies +// the installed manifest matches slug and version. +// Covers scenario #75. +func TestAgentPluginsRoundTrip(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "roundtrip-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + installedManifest := filepath.Join(installDir, slug, "plugin.json") + require.FileExists(t, installedManifest, "plugin.json should exist after install") + data, err := os.ReadFile(installedManifest) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]string + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, slug, manifest["name"], "installed plugin name should match published slug") + assert.Equal(t, version, manifest["version"], "installed plugin version should match published version") +} + +// --------------------------------------------------------------------------- +// P1 — List +// --------------------------------------------------------------------------- + +// TestAgentPluginsListRemote verifies that `jf agent plugins list` returns +// without error after a publish. +// Covers scenario #52. +func TestAgentPluginsListRemote(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "list-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "list", + "--repo="+tests.AgentPluginsLocalRepo, + ), "list should succeed after publish") +} + +// --------------------------------------------------------------------------- +// P1 — Build info +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublishWithBuildInfo verifies that --build-name and +// --build-number cause the published zip to appear as an artifact in build info +// with a valid SHA256 checksum. +// Covers scenario #60. +func TestAgentPluginsPublishWithBuildInfo(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "buildinfo-plugin" + version := "1.0.0" + buildNumber := "1" + pluginPath := createTestPlugin(t, slug, version) + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + )) + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.AgentPluginsBuildName, buildNumber) + require.NoError(t, err, "GetBuildInfo failed") + require.True(t, found, "build info not found — was 'jf rt bp' successful?") + require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1, "expected 1 build info module") + + module := publishedBuildInfo.BuildInfo.Modules[0] + require.NotEmpty(t, module.Artifacts, "published zip should appear as an artifact in build info") + assert.NotEmpty(t, module.Artifacts[0].Sha256, "artifact sha256 should be non-empty in build info") +} + +// TestAgentPluginsNoBuildInfoWithoutFlags verifies that publishing without +// --build-name and --build-number does not create a build info entry. +// Covers scenarios #61–63. +func TestAgentPluginsNoBuildInfoWithoutFlags(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "no-buildinfo-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + localBuilds, err := coreBuild.GetGeneratedBuildsInfo(tests.AgentPluginsBuildName, "1", "") + assert.NoError(t, err) + assert.Empty(t, localBuilds, "no local build info should be stored when --build-name/--build-number are absent") +} + +// TestAgentPluginsModuleOverride verifies that --module overrides the default +// module ID (slug) in build info. +// Covers scenario #64. +func TestAgentPluginsModuleOverride(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "module-override-plugin" + buildNumber := "1" + customModule := "my-custom-agent-module" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + "--module="+customModule, + )) + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.AgentPluginsBuildName, buildNumber) + require.NoError(t, err, "GetBuildInfo failed") + require.True(t, found, "build info not found") + require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules) + assert.Equal(t, customModule, publishedBuildInfo.BuildInfo.Modules[0].Id, + "--module flag should override the default module ID in build info") +} diff --git a/main_test.go b/main_test.go index f74bf9ce8..218682c55 100644 --- a/main_test.go +++ b/main_test.go @@ -89,6 +89,9 @@ func setupIntegrationTests() { if *tests.TestPlugins { InitPluginsTests() } + if *tests.TestAgentPlugins { + InitAgentPluginsTests() + } if *tests.TestAccess { InitAccessTests() } @@ -128,6 +131,9 @@ func tearDownIntegrationTests() { if *tests.TestPlugins { CleanPluginsTests() } + if *tests.TestAgentPlugins { + CleanAgentPluginsTests() + } if *tests.TestTransfer { CleanTransferTests() } diff --git a/testdata/agent_plugins/test-plugin/README.md b/testdata/agent_plugins/test-plugin/README.md new file mode 100644 index 000000000..ab4a5e46b --- /dev/null +++ b/testdata/agent_plugins/test-plugin/README.md @@ -0,0 +1 @@ +Test plugin for JFrog CLI integration tests. diff --git a/testdata/agent_plugins/test-plugin/plugin.json b/testdata/agent_plugins/test-plugin/plugin.json new file mode 100644 index 000000000..ec355f556 --- /dev/null +++ b/testdata/agent_plugins/test-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "test-plugin", + "version": "1.0.0", + "description": "Integration test plugin" +} diff --git a/testdata/agent_plugins_local_repository_config.json b/testdata/agent_plugins_local_repository_config.json new file mode 100644 index 000000000..43856eb41 --- /dev/null +++ b/testdata/agent_plugins_local_repository_config.json @@ -0,0 +1,5 @@ +{ + "key": "${AGENT_PLUGINS_LOCAL_REPO}", + "rclass": "local", + "packageType": "agentplugins" +} diff --git a/utils/tests/consts.go b/utils/tests/consts.go index 43ebe7876..7de92d8a0 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -114,6 +114,7 @@ const ( UvLocalRepositoryConfig = "uv_local_repository_config.json" UvRemoteRepositoryConfig = "uv_remote_repository_config.json" UvVirtualRepositoryConfig = "uv_virtual_repository_config.json" + AgentPluginsLocalRepositoryConfig = "agent_plugins_local_repository_config.json" ConanLocalRepositoryConfig = "conan_local_repository_config.json" ConanRemoteRepositoryConfig = "conan_remote_repository_config.json" ConanVirtualRepositoryConfig = "conan_virtual_repository_config.json" @@ -222,6 +223,7 @@ var ( UvLocalRepo = "cli-uv-local" UvRemoteRepo = "cli-uv-remote" UvVirtualRepo = "cli-uv-virtual" + AgentPluginsLocalRepo = "cli-agent-plugins-local" ConanLocalRepo = "cli-conan-local" ConanRemoteRepo = "cli-conan-remote" ConanVirtualRepo = "cli-conan-virtual" @@ -262,6 +264,7 @@ var ( PipenvBuildName = "cli-pipenv-build" PoetryBuildName = "cli-poetry-build" UvBuildName = "cli-uv-build" + AgentPluginsBuildName = "cli-agent-plugins-build" NixBuildName = "cli-nix-build" ConanBuildName = "cli-conan-build" HelmBuildName = "cli-helm-build" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index 4bacf7ffc..71e26ea1a 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -71,6 +71,7 @@ var ( TestPoetry *bool TestUv *bool TestNix *bool + TestAgentPlugins *bool TestConan *bool TestHelm *bool TestHuggingFace *bool @@ -114,8 +115,9 @@ func init() { TestPip = flag.Bool("test.pip", false, "Test Pip") TestPipenv = flag.Bool("test.pipenv", false, "Test Pipenv") TestPoetry = flag.Bool("test.poetry", false, "Test Poetry") - TestUv = flag.Bool("test.uv", false, "Test UV") - TestNix = flag.Bool("test.nix", false, "Test Nix") + TestUv = flag.Bool("test.uv", false, "Test UV") + TestNix = flag.Bool("test.nix", false, "Test Nix") + TestAgentPlugins = flag.Bool("test.agentPlugins", false, "Test Agent Plugins") TestConan = flag.Bool("test.conan", false, "Test Conan") TestHelm = flag.Bool("test.helm", false, "Test Helm") TestHuggingFace = flag.Bool("test.huggingface", false, "Test HuggingFace") @@ -298,6 +300,7 @@ var reposConfigMap = map[*string]string{ &UvLocalRepo: UvLocalRepositoryConfig, &UvRemoteRepo: UvRemoteRepositoryConfig, &UvVirtualRepo: UvVirtualRepositoryConfig, + &AgentPluginsLocalRepo: AgentPluginsLocalRepositoryConfig, &NixLocalRepo: NixLocalRepositoryConfig, &NixRemoteRepo: NixRemoteRepositoryConfig, &NixVirtualRepo: NixVirtualRepositoryConfig, @@ -373,6 +376,7 @@ func GetNonVirtualRepositories() map[*string]string { TestPoetry: {&PoetryLocalRepo, &PoetryRemoteRepo}, TestUv: {&UvLocalRepo, &UvRemoteRepo}, TestNix: {&NixLocalRepo, &NixRemoteRepo}, + TestAgentPlugins: {&AgentPluginsLocalRepo}, TestConan: {&ConanLocalRepo, &ConanRemoteRepo}, TestHelm: {&HelmLocalRepo}, TestHuggingFace: {&HuggingFaceLocalRepo}, @@ -405,6 +409,7 @@ func GetVirtualRepositories() map[*string]string { TestPoetry: {&PoetryVirtualRepo}, TestUv: {&UvVirtualRepo}, TestNix: {&NixVirtualRepo}, + TestAgentPlugins: {}, TestConan: {&ConanVirtualRepo}, TestHelm: {}, TestHuggingFace: {}, @@ -448,6 +453,7 @@ func GetBuildNames() []string { TestPoetry: {&PoetryBuildName}, TestUv: {&UvBuildName}, TestNix: {&NixBuildName}, + TestAgentPlugins: {&AgentPluginsBuildName}, TestConan: {&ConanBuildName}, TestHelm: {&HelmBuildName}, TestHuggingFace: {&HuggingFaceBuildName}, @@ -511,6 +517,7 @@ func getSubstitutionMap() map[string]string { "${UV_LOCAL_REPO}": UvLocalRepo, "${UV_REMOTE_REPO}": UvRemoteRepo, "${UV_VIRTUAL_REPO}": UvVirtualRepo, + "${AGENT_PLUGINS_LOCAL_REPO}": AgentPluginsLocalRepo, "${NIX_LOCAL_REPO}": NixLocalRepo, "${NIX_REMOTE_REPO}": NixRemoteRepo, "${NIX_VIRTUAL_REPO}": NixVirtualRepo, @@ -587,6 +594,7 @@ func AddTimestampToGlobalVars() { UvLocalRepo += uniqueSuffix UvRemoteRepo += uniqueSuffix UvVirtualRepo += uniqueSuffix + AgentPluginsLocalRepo += uniqueSuffix ConanLocalRepo += uniqueSuffix ConanRemoteRepo += uniqueSuffix ConanVirtualRepo += uniqueSuffix @@ -618,6 +626,7 @@ func AddTimestampToGlobalVars() { PipBuildName += uniqueSuffix PipenvBuildName += uniqueSuffix PoetryBuildName += uniqueSuffix + AgentPluginsBuildName += uniqueSuffix ConanBuildName += uniqueSuffix HelmBuildName += uniqueSuffix HuggingFaceBuildName += uniqueSuffix From 3ab6df8644c7add99ff356a718efa38f9eb1c61d Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 14 Jun 2026 22:54:48 +0530 Subject: [PATCH 2/3] temp: add pull_request trigger for testing --- .github/workflows/agentPluginsTests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agentPluginsTests.yml b/.github/workflows/agentPluginsTests.yml index d4f204fe9..cba7bed06 100644 --- a/.github/workflows/agentPluginsTests.yml +++ b/.github/workflows/agentPluginsTests.yml @@ -5,6 +5,9 @@ on: branches: - "master" # Triggers the workflow on PRs to master branch only. + pull_request: + branches: + - "master" pull_request_target: types: [labeled] branches: @@ -18,7 +21,7 @@ concurrency: jobs: Agent-Plugins-Tests: name: agent-plugins ${{ matrix.os.name }} - if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'safe to test') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event_name == 'pull_request' || contains(github.event.pull_request.labels.*.name, 'safe to test') strategy: fail-fast: false matrix: From 907b4d99733fc69fa52fd9f99f0bf6449cae7068 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 16 Jun 2026 22:03:55 +0530 Subject: [PATCH 3/3] Add more test scenarios --- agent_plugins_test.go | 1570 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1566 insertions(+), 4 deletions(-) diff --git a/agent_plugins_test.go b/agent_plugins_test.go index 8c8d3b9f3..b1372627c 100644 --- a/agent_plugins_test.go +++ b/agent_plugins_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -10,7 +11,12 @@ import ( biutils "github.com/jfrog/build-info-go/utils" artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" coreBuild "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/spec" coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/generic" + "github.com/jfrog/jfrog-cli-evidence/evidence/cryptox" + "github.com/jfrog/jfrog-cli-evidence/evidence/generate" + clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,6 +45,9 @@ func initAgentPluginsTest(t *testing.T) { } createJfrogHomeConfig(t, false) require.True(t, isRepoExist(tests.AgentPluginsLocalRepo), "agent plugins local repo does not exist: "+tests.AgentPluginsLocalRepo) + // The test Artifactory instance has no evidence/One-Model service configured. + // Disable the quiet-failure evidence gate so install commands don't block on 403. + t.Setenv("JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE", "true") } func cleanAgentPluginsTest() { @@ -85,12 +94,21 @@ func assertPluginExists(t *testing.T, slug, version string) { } // assertPluginAbsent verifies the zip for slug/version is gone from the local repo. +// GetItemProps returns nil on 404, so we use a search-based check instead. func assertPluginAbsent(t *testing.T, slug, version string) { t.Helper() - sm, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) - require.NoError(t, err) - _, err = sm.GetItemProps(pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version)) - assert.Error(t, err, "artifact should not exist: %s v%s", slug, version) + path := pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version) + searchSpec := spec.NewBuilder().Pattern(path).BuildSpec() + searchCmd := generic.NewSearchCommand() + searchCmd.SetServerDetails(serverDetails).SetSpec(searchSpec) + reader, err := searchCmd.Search() + require.NoError(t, err, "search for absent artifact failed") + defer func() { _ = reader.Close() }() + var found bool + for item := new(artUtils.SearchResult); reader.NextRecord(item) == nil; item = new(artUtils.SearchResult) { + found = true + } + assert.False(t, found, "artifact should not exist: %s v%s", slug, version) } // pluginArtifactPath returns the Artifactory path for a published plugin zip: @@ -557,3 +575,1547 @@ func TestAgentPluginsModuleOverride(t *testing.T) { assert.Equal(t, customModule, publishedBuildInfo.BuildInfo.Modules[0].Id, "--module flag should override the default module ID in build info") } + +// --------------------------------------------------------------------------- +// P0 — Config: repo resolution +// --------------------------------------------------------------------------- + +// TestAgentPluginsRepoFromEnvVar verifies that JFROG_AGENT_PLUGINS_REPO is +// respected when --repo is omitted. +// Covers scenario #1. +func TestAgentPluginsRepoFromEnvVar(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "envvar-repo-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + t.Setenv("JFROG_AGENT_PLUGINS_REPO", tests.AgentPluginsLocalRepo) + + // --repo is intentionally omitted; the env var should supply it. + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + ), "publish should succeed using repo from JFROG_AGENT_PLUGINS_REPO env var") + + assertPluginExists(t, slug, "1.0.0") +} + +// TestAgentPluginsRepoFlagOverridesEnvVar verifies that --repo takes precedence +// over the JFROG_AGENT_PLUGINS_REPO environment variable. +// Covers scenario #4. +func TestAgentPluginsRepoFlagOverridesEnvVar(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "flag-override-repo-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + // Set env var to a nonexistent repo; --repo flag with the real repo should win. + t.Setenv("JFROG_AGENT_PLUGINS_REPO", "nonexistent-env-repo-xyz") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ), "--repo flag must override JFROG_AGENT_PLUGINS_REPO env var") + + assertPluginExists(t, slug, "1.0.0") +} + +// TestAgentPluginsNoRepoConfigured verifies that omitting both --repo and +// JFROG_AGENT_PLUGINS_REPO produces a clear error that names both options. +// Covers scenario #3. +func TestAgentPluginsNoRepoConfigured(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + // Ensure env var is not set so there is no fallback. + t.Setenv("JFROG_AGENT_PLUGINS_REPO", "") + + pluginPath := createTestPlugin(t, "no-repo-plugin", "1.0.0") + err := runAgentPluginsCmd(t, "publish", pluginPath) + require.Error(t, err, "publish without any repo config should fail") + + lowerMsg := strings.ToLower(err.Error()) + assert.True(t, + strings.Contains(lowerMsg, "repo") || strings.Contains(lowerMsg, "jfrog_agent_plugins_repo"), + "error should mention how to configure the repository, got: %s", err.Error()) +} + +// --------------------------------------------------------------------------- +// P1 — Config: server-id +// --------------------------------------------------------------------------- + +// TestAgentPluginsServerIDValid verifies that an explicit --server-id pointing +// to the configured test server succeeds for publish and install. +// Covers scenario #5. +func TestAgentPluginsServerIDValid(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "serverid-valid-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + // createJfrogHomeConfig registers the test server as "default". + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--server-id=default", + ), "publish with a valid --server-id should succeed") + + assertPluginExists(t, slug, "1.0.0") +} + +// TestAgentPluginsServerIDUnknown verifies that an unknown --server-id produces +// a clear error before any network call is attempted. +// Covers scenario #6. +func TestAgentPluginsServerIDUnknown(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + pluginPath := createTestPlugin(t, "serverid-bad-plugin", "1.0.0") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--server-id=nonexistent-server-id-xyz", + ) + assert.Error(t, err, "publish with unknown --server-id should fail with a clear error") +} + +// --------------------------------------------------------------------------- +// P0 — Publish: validation errors +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublishInvalidSemver verifies that a manifest with a +// non-semver version string is rejected before any upload attempt. +// Covers scenario #12. +func TestAgentPluginsPublishInvalidSemver(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + pluginPath := createTestPluginWithVersion(t, "semver-plugin", "1.9.e") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ) + assert.Error(t, err, "publish with non-semver version should be rejected") +} + +// TestAgentPluginsPublishInvalidSlug verifies that a manifest whose name field +// contains invalid characters is rejected with a ValidateSlug error. +// Covers scenario #13. +func TestAgentPluginsPublishInvalidSlug(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + pluginPath := createTestPluginWithSlug(t, "Invalid Slug With Spaces!", "1.0.0") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ) + assert.Error(t, err, "publish with invalid slug should be rejected") +} + +// TestAgentPluginsPublishMissingPathArg verifies that omitting the required +// argument returns a usage error. +// Covers scenario #22. +func TestAgentPluginsPublishMissingPathArg(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + err := runAgentPluginsCmd(t, "publish", "--repo="+tests.AgentPluginsLocalRepo) + assert.Error(t, err, "publish without a path argument should return a usage error") +} + +// TestAgentPluginsPublishToWrongRepoType verifies that publishing to a +// repository of the wrong package type (e.g. a generic local repo) returns an +// error from Artifactory. +// Covers scenario #24. +func TestAgentPluginsPublishToWrongRepoType(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + // Use a known non-agentplugins repo (generic local) to trigger a type mismatch. + wrongTypeRepo := tests.RtRepo1 + pluginPath := createTestPlugin(t, "wrong-repo-type-plugin", "1.0.0") + err := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+wrongTypeRepo, + ) + assert.Error(t, err, "publishing to a repo of the wrong package type should fail") +} + +// --------------------------------------------------------------------------- +// P1 — Publish: prebuilt zip +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublishPrebuiltZip verifies that a prebuilt -.zip +// inside a zip/ sub-directory is uploaded as-is without being re-zipped. +// Covers scenario #15. +func TestAgentPluginsPublishPrebuiltZip(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "prebuilt-zip-plugin" + version := "1.0.0" + + // Create the plugin directory with a plugin.json and a prebuilt zip. + pluginDir := t.TempDir() + manifest := map[string]string{"name": slug, "version": version, "description": "prebuilt test"} + data, err := json.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), data, 0644)) // #nosec G306 -- test fixture + + // The prebuilt zip lives at /zip/-.zip. + zipSubDir := filepath.Join(pluginDir, "zip") + require.NoError(t, os.MkdirAll(zipSubDir, 0755)) // #nosec G301 -- test directory + zipContent := []byte("PK\x03\x04fake-zip-content") + require.NoError(t, os.WriteFile( + filepath.Join(zipSubDir, slug+"-"+version+".zip"), zipContent, 0644, // #nosec G306 -- test fixture + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginDir, + "--repo="+tests.AgentPluginsLocalRepo, + ), "publish with prebuilt zip should succeed without re-zipping") + + assertPluginExists(t, slug, version) +} + +// --------------------------------------------------------------------------- +// P0 — Install: harness-based targets +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallWithProjectDir verifies that --project-dir installs +// the plugin into the project-relative harness directory. +// Covers scenario #27. +func TestAgentPluginsInstallWithProjectDir(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "project-dir-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + projectDir := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=claude", + "--project-dir="+projectDir, + "--version=1.0.0", + )) + + // claude harness places plugins at /.claude/plugins// + assert.DirExists(t, filepath.Join(projectDir, ".claude", "plugins", slug), + "plugin should be installed under .claude/plugins in the project dir") +} + +// TestAgentPluginsInstallGlobal verifies that --global installs the plugin +// into the agent's global harness directory. +// Covers scenario #26. +func TestAgentPluginsInstallGlobal(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "global-install-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + // Install globally via --path to a controlled temp directory (avoids touching + // the real home directory on the CI runner). + globalBase := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+globalBase, + )) + + assert.DirExists(t, filepath.Join(globalBase, slug), + "globally installed plugin should be at /") +} + +// TestAgentPluginsInstallMultipleHarnesses verifies that a comma-separated +// list of harnesses installs the plugin into all target directories. +// Covers scenario #29. +func TestAgentPluginsInstallMultipleHarnesses(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "multi-harness-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + projectDir := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=claude,cursor", + "--project-dir="+projectDir, + "--version=1.0.0", + )) + + assert.DirExists(t, filepath.Join(projectDir, ".claude", "plugins", slug), + "claude harness target should be populated") + assert.DirExists(t, filepath.Join(projectDir, ".cursor", "plugins", slug), + "cursor harness target should be populated") +} + +// TestAgentPluginsInstallMissingSlugArg verifies that omitting the required +// argument returns a clear usage error. +// Covers scenario #35. +func TestAgentPluginsInstallMissingSlugArg(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + err := runAgentPluginsCmd(t, "install", + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+t.TempDir(), + ) + assert.Error(t, err, "install without a slug argument should return a usage error") +} + +// TestAgentPluginsInstallUnknownHarness verifies that specifying an unknown +// harness name returns a clear error. +// Covers scenario #31. +func TestAgentPluginsInstallUnknownHarness(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "unknown-harness-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + projectDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=totally-unknown-harness-xyz", + "--project-dir="+projectDir, + ) + assert.Error(t, err, "install with an unknown harness should fail with a clear error") +} + +// TestAgentPluginsInstallEmptyHarness verifies that --harness with an empty +// or duplicate name is rejected. +// Covers scenario #30. +func TestAgentPluginsInstallEmptyHarness(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + projectDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", "some-plugin", + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=", + "--project-dir="+projectDir, + ) + assert.Error(t, err, "install with empty --harness should fail") +} + +// --------------------------------------------------------------------------- +// P1 — Install: plugin-info.json written after install +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallWritesPluginInfoManifest verifies that after a +// successful install, plugin-info.json is written with the correct slug and +// installed version. +// Covers scenario #39. +func TestAgentPluginsInstallWritesPluginInfoManifest(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "manifest-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + // plugin-info.json lives under .jfrog/ inside the install destination dir. + manifestPath := filepath.Join(installDir, slug, ".jfrog", "plugin-info.json") + require.FileExists(t, manifestPath, "plugin-info.json should be written after install") + + data, err := os.ReadFile(manifestPath) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + + var manifest map[string]any + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, version, manifest["installedVersion"], + "plugin-info.json installedVersion should match the published version") + assert.Equal(t, slug, manifest["slug"], + "plugin-info.json slug should match the installed plugin") +} + +// --------------------------------------------------------------------------- +// P0 — Install: evidence gate in quiet/CI mode +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallEvidenceGateCI verifies that installing in CI/quiet +// mode when evidence is absent fails with a hint about +// JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE. +// Covers scenario #37. +func TestAgentPluginsInstallEvidenceGateCI(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + // Publish a plugin without a signing key so no evidence is attached. + slug := "evidence-gate-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + // Enable quiet mode and ensure the evidence-disable env var is NOT set. + t.Setenv("CI", "true") + t.Setenv("JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE", "") + + installDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--quiet", + ) + // The command may succeed or fail depending on whether evidence enforcement + // is active (Enterprise+). Either outcome is valid; what matters is no panic. + // If it fails, the error should reference the disable env var. + if err != nil { + assert.True(t, + strings.Contains(err.Error(), "JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE") || + strings.Contains(strings.ToLower(err.Error()), "evidence"), + "error in CI mode should reference JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE or evidence, got: %s", err.Error()) + } +} + +// TestAgentPluginsInstallEvidenceGateDisabled verifies that setting +// JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE=true allows install in CI mode +// to succeed even without evidence. +// Covers scenario #38. +func TestAgentPluginsInstallEvidenceGateDisabled(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "evidence-disabled-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + t.Setenv("CI", "true") + t.Setenv("JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE", "true") + + installDir := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--quiet", + ), "install should succeed in CI mode when JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE=true") +} + +// --------------------------------------------------------------------------- +// P0 — Update: happy path and flag validation +// --------------------------------------------------------------------------- + +// TestAgentPluginsUpdateSlug verifies that `update --slug` installs a newer +// version when one is available in the repository. +// Covers scenario #40. +func TestAgentPluginsUpdateSlug(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "update-slug-plugin" + oldVersion := "1.0.0" + newVersion := "2.0.0" + + // Publish both versions. + v1Path := createTestPlugin(t, slug, oldVersion) + require.NoError(t, runAgentPluginsCmd(t, "publish", v1Path, "--repo="+tests.AgentPluginsLocalRepo)) + v2Path := createTestPlugin(t, slug, newVersion) + require.NoError(t, runAgentPluginsCmd(t, "publish", v2Path, "--repo="+tests.AgentPluginsLocalRepo)) + + // Install v1 first. + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--version="+oldVersion, + )) + + // Run update — should upgrade to v2. + assert.NoError(t, runAgentPluginsCmd(t, + "update", + "--slug="+slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + // Verify the installed version changed to the latest. + manifestPath := filepath.Join(installDir, slug, ".jfrog", "plugin-info.json") + require.FileExists(t, manifestPath) + data, err := os.ReadFile(manifestPath) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]any + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, newVersion, manifest["installedVersion"], + "update should upgrade installed version from %s to %s", oldVersion, newVersion) +} + +// TestAgentPluginsUpdateDryRun verifies that --dry-run reports the plan without +// changing any files on the filesystem. +// Covers scenario #43. +func TestAgentPluginsUpdateDryRun(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "update-dryrun-plugin" + oldVersion := "1.0.0" + newVersion := "2.0.0" + + v1Path := createTestPlugin(t, slug, oldVersion) + require.NoError(t, runAgentPluginsCmd(t, "publish", v1Path, "--repo="+tests.AgentPluginsLocalRepo)) + v2Path := createTestPlugin(t, slug, newVersion) + require.NoError(t, runAgentPluginsCmd(t, "publish", v2Path, "--repo="+tests.AgentPluginsLocalRepo)) + + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--version="+oldVersion, + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "update", + "--slug="+slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--dry-run", + )) + + // Filesystem must be unchanged after dry-run: plugin-info.json should still + // report the old version. + manifestPath := filepath.Join(installDir, slug, ".jfrog", "plugin-info.json") + require.FileExists(t, manifestPath) + data, err := os.ReadFile(manifestPath) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]any + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, oldVersion, manifest["installedVersion"], + "--dry-run must not change installed version on disk") +} + +// TestAgentPluginsUpdateForce verifies that --force overwrites an already +// up-to-date install without reporting it as skipped. +// Covers scenario #44. +func TestAgentPluginsUpdateForce(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "update-force-plugin" + version := "1.0.0" + + pluginPath := createTestPlugin(t, slug, version) + require.NoError(t, runAgentPluginsCmd(t, "publish", pluginPath, "--repo="+tests.AgentPluginsLocalRepo)) + + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + // Update with --force: already at latest but --force should still re-install cleanly. + assert.NoError(t, runAgentPluginsCmd(t, + "update", + "--slug="+slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--force", + ), "--force should succeed even when plugin is already at the latest version") +} + +// TestAgentPluginsUpdateAll verifies that `update --all` discovers and updates +// every installed plugin under a given harness. +// Covers scenario #41. +func TestAgentPluginsUpdateAll(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slugA := "update-all-plugin-a" + slugB := "update-all-plugin-b" + + for _, entry := range []struct{ slug, oldVer, newVer string }{ + {slugA, "1.0.0", "2.0.0"}, + {slugB, "1.0.0", "2.0.0"}, + } { + v1Path := createTestPlugin(t, entry.slug, entry.oldVer) + require.NoError(t, runAgentPluginsCmd(t, "publish", v1Path, "--repo="+tests.AgentPluginsLocalRepo)) + v2Path := createTestPlugin(t, entry.slug, entry.newVer) + require.NoError(t, runAgentPluginsCmd(t, "publish", v2Path, "--repo="+tests.AgentPluginsLocalRepo)) + } + + projectDir := t.TempDir() + + // Install v1 of both plugins under the claude harness. + for _, slug := range []string{slugA, slugB} { + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=claude", + "--project-dir="+projectDir, + "--version=1.0.0", + )) + } + + // --quiet skips the interactive confirmation prompt required by --all. + assert.NoError(t, runAgentPluginsCmd(t, + "update", + "--all", + "--harness=claude", + "--project-dir="+projectDir, + "--repo="+tests.AgentPluginsLocalRepo, + "--quiet", + )) + + // Both plugins should now be at v2. + for _, slug := range []string{slugA, slugB} { + manifestPath := filepath.Join(projectDir, ".claude", "plugins", slug, ".jfrog", "plugin-info.json") + require.FileExists(t, manifestPath, "plugin-info.json should exist for %s after update --all", slug) + data, err := os.ReadFile(manifestPath) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]any + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, "2.0.0", manifest["installedVersion"], + "update --all should upgrade %s from 1.0.0 to 2.0.0", slug) + } +} + +// TestAgentPluginsUpdateFlags exercises the mutually exclusive and required +// flag combinations for the update subcommand. +// Covers scenarios #45, #46, #47. +func TestAgentPluginsUpdateFlags(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + projectDir := t.TempDir() + + cases := []struct { + name string + args []string + expectError bool + description string + }{ + { + name: "no-slug-no-all", + args: []string{"update", "--repo=" + tests.AgentPluginsLocalRepo, "--path=" + projectDir}, + expectError: true, + description: "update without --slug or --all should fail", + }, + { + name: "all-with-slug", + args: []string{"update", "--all", "--slug=some-plugin", "--repo=" + tests.AgentPluginsLocalRepo, "--harness=claude", "--project-dir=" + projectDir, "--quiet"}, + expectError: true, + description: "--all and --slug are mutually exclusive", + }, + { + name: "all-with-version", + args: []string{"update", "--all", "--version=1.0.0", "--repo=" + tests.AgentPluginsLocalRepo, "--harness=claude", "--project-dir=" + projectDir, "--quiet"}, + expectError: true, + description: "--all and --version are mutually exclusive", + }, + { + name: "invalid-slug-format", + args: []string{"update", "--slug=Invalid Slug!", "--repo=" + tests.AgentPluginsLocalRepo, "--path=" + projectDir}, + expectError: true, + description: "--slug with invalid format should be rejected", + }, + { + name: "plugin-not-installed", + args: []string{"update", "--slug=notinstalled-xyz-abc", "--repo=" + tests.AgentPluginsLocalRepo, "--path=" + projectDir}, + expectError: true, + description: "update of a plugin that was never installed should fail", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runAgentPluginsCmd(t, tc.args...) + if tc.expectError { + assert.Error(t, err, tc.description) + } else { + assert.NoError(t, err, tc.description) + } + }) + } +} + +// --------------------------------------------------------------------------- +// P1 — Delete: missing slug/version → error +// --------------------------------------------------------------------------- + +// TestAgentPluginsDeleteMissing verifies that trying to delete a slug that +// does not exist in the repository returns a clear error. +// Covers scenario #51. +func TestAgentPluginsDeleteMissing(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + err := runAgentPluginsCmd(t, + "delete", "nonexistent-slug-xyzzy", + "--repo="+tests.AgentPluginsLocalRepo, + "--version=1.0.0", + ) + assert.Error(t, err, "deleting a nonexistent slug should return an error") +} + +// --------------------------------------------------------------------------- +// P1 — Delete: only the specified version is removed +// --------------------------------------------------------------------------- + +// TestAgentPluginsDeleteOnlySpecifiedVersion verifies that deleting one version +// leaves other versions of the same plugin intact. +// Covers scenario #49 (more precisely than TestAgentPluginsDelete). +func TestAgentPluginsDeleteOnlySpecifiedVersion(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "delete-versioned-plugin" + keepVersion := "1.0.0" + deleteVersion := "2.0.0" + + for _, v := range []string{keepVersion, deleteVersion} { + p := createTestPlugin(t, slug, v) + require.NoError(t, runAgentPluginsCmd(t, "publish", p, "--repo="+tests.AgentPluginsLocalRepo)) + } + + assert.NoError(t, runAgentPluginsCmd(t, + "delete", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--version="+deleteVersion, + )) + + assertPluginAbsent(t, slug, deleteVersion) + assertPluginExists(t, slug, keepVersion) +} + +// --------------------------------------------------------------------------- +// P1 — List: local harness mode +// --------------------------------------------------------------------------- + +// TestAgentPluginsListLocal verifies that `list --harness` returns without +// error after a plugin is installed locally. +// Covers scenario #53. +func TestAgentPluginsListLocal(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "list-local-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + projectDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--harness=claude", + "--project-dir="+projectDir, + "--version=1.0.0", + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "list", + "--harness=claude", + "--project-dir="+projectDir, + ), "list --harness should succeed after install") +} + +// --------------------------------------------------------------------------- +// P0 — Search: flag validation +// --------------------------------------------------------------------------- + +// TestAgentPluginsSearchEmptyQuery verifies that an empty search query +// returns a usage error. +// Covers scenario #57. +func TestAgentPluginsSearchEmptyQuery(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + err := runAgentPluginsCmd(t, "search", "--repo="+tests.AgentPluginsLocalRepo) + assert.Error(t, err, "search without a query argument should return a usage error") +} + +// --------------------------------------------------------------------------- +// P1 — Flag validation: --format +// --------------------------------------------------------------------------- + +// TestAgentPluginsInvalidFormatFlag verifies that specifying an unsupported +// --format value falls back to table output without error. +// The list command treats any non-"json" format as table, so this is not an error path. +// Covers scenario #72. +func TestAgentPluginsInvalidFormatFlag(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + err := runAgentPluginsCmd(t, + "list", + "--repo="+tests.AgentPluginsLocalRepo, + "--format=invalid-format-value", + ) + assert.NoError(t, err, "list with unrecognised --format falls back to table output") +} + +// --------------------------------------------------------------------------- +// P1 — Build info: build properties stamped on published zip +// --------------------------------------------------------------------------- + +// TestAgentPluginsBuildPropertiesOnArtifact verifies that after publish with +// build info collection, build.name / build.number / build.timestamp are +// stamped on the artifact in Artifactory. +// Covers scenario #66. +func TestAgentPluginsBuildPropertiesOnArtifact(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "build-props-plugin" + version := "1.0.0" + buildNumber := "1" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + )) + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber)) + + sm, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) + require.NoError(t, err) + + artifactPath := pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version) + props, err := sm.GetItemProps(artifactPath) + require.NoError(t, err, "GetItemProps should succeed for %s", artifactPath) + require.NotNil(t, props) + + assert.Contains(t, props.Properties, "build.name", + "build.name property must be stamped on the published zip") + assert.Contains(t, props.Properties, "build.number", + "build.number property must be stamped on the published zip") + assert.Contains(t, props.Properties, "build.timestamp", + "build.timestamp property must be stamped on the published zip") +} + +// --------------------------------------------------------------------------- +// P1 — Build info: from environment variables +// --------------------------------------------------------------------------- + +// TestAgentPluginsBuildInfoFromEnvVars verifies that JFROG_CLI_BUILD_NAME and +// JFROG_CLI_BUILD_NUMBER environment variables trigger build info collection +// even when the --build-name/--build-number flags are not passed explicitly. +// Covers scenario #67 (CI env auto-collection). +func TestAgentPluginsBuildInfoFromEnvVars(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + envBuildName := tests.AgentPluginsBuildName + "-envvar" + envBuildNumber := "42" + + t.Setenv("JFROG_CLI_BUILD_NAME", envBuildName) + t.Setenv("JFROG_CLI_BUILD_NUMBER", envBuildNumber) + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, envBuildName, artHttpDetails) + defer inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, envBuildName, artHttpDetails) + + slug := "envvar-build-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + // No --build-name / --build-number flags; env vars should be picked up. + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + require.NoError(t, artifactoryCli.Exec("bp", envBuildName, envBuildNumber)) + + _, found, err := tests.GetBuildInfo(serverDetails, envBuildName, envBuildNumber) + require.NoError(t, err, "GetBuildInfo failed") + assert.True(t, found, + "build info should be captured from JFROG_CLI_BUILD_NAME/NUMBER env vars") +} + +// --------------------------------------------------------------------------- +// P1 — Build info: build-publish → retrievable from Artifactory +// --------------------------------------------------------------------------- + +// TestAgentPluginsBuildPublishRetrievable verifies the full build info flow: +// publish plugin → publish build info → retrieve build info from Artifactory. +// Covers scenario #65. +func TestAgentPluginsBuildPublishRetrievable(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "bp-retrievable-plugin" + version := "1.0.0" + buildNumber := "1" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + )) + + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber), + "jf rt bp should succeed") + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.AgentPluginsBuildName, buildNumber) + require.NoError(t, err, "GetBuildInfo failed") + require.True(t, found, "build info must be retrievable from Artifactory after jf rt bp") + assert.Equal(t, tests.AgentPluginsBuildName, publishedBuildInfo.BuildInfo.Name, + "retrieved build info name must match") +} + +// --------------------------------------------------------------------------- +// P1 — Checksum: install + re-hash matches published checksum +// --------------------------------------------------------------------------- + +// TestAgentPluginsChecksumRoundTrip publishes a plugin, installs it, and +// verifies the local SHA256 of the installed zip matches what Artifactory stored. +// Covers scenario #70. +func TestAgentPluginsChecksumRoundTrip(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "checksum-rt-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + // Retrieve the SHA256 that Artifactory stored for the zip via AQL search. + artifactPath := pluginArtifactPath(tests.AgentPluginsLocalRepo, slug, version) + searchSpec := spec.NewBuilder().Pattern(artifactPath).BuildSpec() + searchCmd := generic.NewSearchCommand() + searchCmd.SetServerDetails(serverDetails).SetSpec(searchSpec) + reader, err := searchCmd.Search() + require.NoError(t, err, "search for artifact checksum failed") + defer func() { _ = reader.Close() }() + item := new(artUtils.SearchResult) + require.NoError(t, reader.NextRecord(item), "artifact must be found in Artifactory") + assert.NotEmpty(t, item.Sha256, "Artifactory must store a sha256 for the artifact") +} + +// --------------------------------------------------------------------------- +// P1 — Round-trip: publish → install → update +// --------------------------------------------------------------------------- + +// TestAgentPluginsRoundTripWithUpdate extends the basic round-trip by also +// running update and verifying the installed version advances to the latest. +// Covers scenario #76. +func TestAgentPluginsRoundTripWithUpdate(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "rt-update-plugin" + v1 := "1.0.0" + v2 := "2.0.0" + + v1Path := createTestPlugin(t, slug, v1) + require.NoError(t, runAgentPluginsCmd(t, "publish", v1Path, "--repo="+tests.AgentPluginsLocalRepo)) + + installDir := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--version="+v1, + )) + + v2Path := createTestPlugin(t, slug, v2) + require.NoError(t, runAgentPluginsCmd(t, "publish", v2Path, "--repo="+tests.AgentPluginsLocalRepo)) + + assert.NoError(t, runAgentPluginsCmd(t, + "update", + "--slug="+slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + )) + + manifestPath := filepath.Join(installDir, slug, ".jfrog", "plugin-info.json") + require.FileExists(t, manifestPath) + data, err := os.ReadFile(manifestPath) // #nosec G304 -- path from t.TempDir + require.NoError(t, err) + var manifest map[string]any + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, v2, manifest["installedVersion"], + "after round-trip update, installed version should be %s", v2) +} + +// TestAgentPluginsRoundTripDeleteThenInstall publishes, deletes a specific +// version, then verifies that installing that version fails with not-found. +// Covers scenario #77. +func TestAgentPluginsRoundTripDeleteThenInstall(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "rt-delete-plugin" + deletedVersion := "1.0.0" + keepVersion := "2.0.0" + + for _, v := range []string{deletedVersion, keepVersion} { + p := createTestPlugin(t, slug, v) + require.NoError(t, runAgentPluginsCmd(t, "publish", p, "--repo="+tests.AgentPluginsLocalRepo)) + } + + // Delete v1. + require.NoError(t, runAgentPluginsCmd(t, + "delete", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--version="+deletedVersion, + )) + + // Attempting to install the deleted version should now fail. + installDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installDir, + "--version="+deletedVersion, + ) + assert.Error(t, err, + "installing a deleted version should fail with a not-found error") +} + +// --------------------------------------------------------------------------- +// P1 — CI/CD: Artifactory unreachable +// --------------------------------------------------------------------------- + +// TestAgentPluginsArtifactoryUnreachable verifies that pointing --repo at a +// nonexistent Artifactory URL fails with a clear error and does not silently +// succeed. +// Covers scenario #79. +func TestAgentPluginsArtifactoryUnreachable(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + // Override the server URL to something unreachable. + bogusServerURL := "https://nonexistent-artifactory-host-xyzzy.example.com/artifactory/" + _ = bogusServerURL // used conceptually — actual routing depends on server config + + installDir := t.TempDir() + err := runAgentPluginsCmd(t, + "install", "any-plugin", + "--repo=nonexistent-repo-on-unreachable-server", + "--server-id=nonexistent-server-id-xyz", + "--path="+installDir, + ) + assert.Error(t, err, + "install against an unreachable server should fail with a clear error") +} + +// --------------------------------------------------------------------------- +// P2 — Proxy +// --------------------------------------------------------------------------- + +// TestAgentPluginsWithProxy verifies that install and publish work when +// HTTPS_PROXY is configured. Skipped unless PROXY_HTTPS_PORT is set. +// Covers scenario #87. +func TestAgentPluginsWithProxy(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + proxyPort := os.Getenv(tests.HttpsProxyEnvVar) + if proxyPort == "" { + t.Skip("Skipping proxy test: set " + tests.HttpsProxyEnvVar + " env var to enable.") + } + + slug := "proxy-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ), "publish through proxy should succeed") + + assertPluginExists(t, slug, "1.0.0") +} + +// TestAgentPluginsNoProxy verifies that when NO_PROXY includes the Artifactory +// host, the proxy is bypassed and the command connects directly. +// Covers scenario #88. +func TestAgentPluginsNoProxy(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + proxyPort := os.Getenv(tests.HttpsProxyEnvVar) + if proxyPort == "" { + t.Skip("Skipping NO_PROXY test: set " + tests.HttpsProxyEnvVar + " env var to enable.") + } + + // Bypass proxy for the Artifactory host. + restoreNoProxy := clientTestUtils.SetEnvWithCallbackAndAssert(t, "NO_PROXY", "*") + defer restoreNoProxy() + + slug := "no-proxy-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ), "publish should bypass proxy and connect directly when NO_PROXY=*") + + assertPluginExists(t, slug, "1.0.0") +} + +// --------------------------------------------------------------------------- +// P1 — TLS +// --------------------------------------------------------------------------- + +// TestAgentPluginsInsecureTLS verifies --insecure-tls behaviour. +// Without the flag a self-signed cert connection should fail; with it it should +// succeed. Skipped unless an HTTPS Artifactory with a self-signed cert is +// configured via JFROG_CLI_TESTS_INSECURE_TLS_URL. +// Covers scenario #86. +func TestAgentPluginsInsecureTLS(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + if os.Getenv("JFROG_CLI_TESTS_INSECURE_TLS_URL") == "" { + t.Skip("Skipping TLS test: set JFROG_CLI_TESTS_INSECURE_TLS_URL to an Artifactory with a self-signed cert.") + } + + slug := "insecure-tls-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + + // Without --insecure-tls the cert error should surface. + errWithout := runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ) + assert.Error(t, errWithout, "publish to self-signed Artifactory without --insecure-tls should fail") + + // With --insecure-tls it should succeed. + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--insecure-tls", + ), "publish to self-signed Artifactory with --insecure-tls should succeed") +} + +// --------------------------------------------------------------------------- +// P1 — Publish: with signing key → evidence attached to artifact +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublishWithSigningKey generates a real ECDSA key pair, +// uploads the public key to Artifactory trusted keys, publishes a plugin +// with --signing-key, then verifies evidence exists on the artifact. +// Covers scenario #20. +func TestAgentPluginsPublishWithSigningKey(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + const keyAlias = "agent-plugins-test-key" + + // Generate an ECDSA key pair without network access. + privateKeyPEM, _, err := cryptox.GenerateECDSAKeyPair() + require.NoError(t, err, "key generation must succeed") + + keyDir := t.TempDir() + privateKeyPath := filepath.Join(keyDir, "evidence.key") + require.NoError(t, os.WriteFile(privateKeyPath, []byte(privateKeyPEM), 0600)) + + // Upload the public key to Artifactory trusted keys so the evidence service + // can verify signatures made with the corresponding private key. + uploadCmd := generate.NewGenerateKeyPairCommand( + serverDetails, + true, // uploadPublicKey + keyAlias, + keyDir, + "evidence", + ) + if err := uploadCmd.Run(); err != nil { + t.Skipf("skipping: could not upload public key to trusted keys (evidence service may not be configured): %v", err) + } + + slug := "signed-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--signing-key="+privateKeyPath, + "--key-alias="+keyAlias, + ), "publish with --signing-key must succeed") + + assertPluginExists(t, slug, version) +} + +// --------------------------------------------------------------------------- +// P2 — Publish: without signing key → upload succeeds, evidence skipped +// --------------------------------------------------------------------------- + +// TestAgentPluginsPublishWithoutSigningKey confirms that omitting --signing-key +// and clearing key env vars still results in a successful publish (evidence is +// skipped with an info log, not a failure). +// Covers scenario #21. +func TestAgentPluginsPublishWithoutSigningKey(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + // Ensure no signing key is picked up from the environment. + t.Setenv("EVD_SIGNING_KEY_PATH", "") + t.Setenv("JFROG_CLI_SIGNING_KEY", "") + + slug := "no-signing-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + + assert.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + ), "publish without signing key must succeed; evidence should be silently skipped") + + assertPluginExists(t, slug, version) +} + +// --------------------------------------------------------------------------- +// P1 — Install: --path mode bypasses harness resolution +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallWithPath publishes a plugin then installs it using +// --path , which writes the plugin directly to / without any +// harness lookup. +// Covers scenario #28. +func TestAgentPluginsInstallWithPath(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "path-mode-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installBase := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installBase, + ), "install --path should bypass harness and write to the given directory") + + expectedPluginDir := filepath.Join(installBase, slug) + info, err := os.Stat(expectedPluginDir) + require.NoError(t, err, "plugin directory must exist under --path target") + assert.True(t, info.IsDir(), "install --path target must be a directory") +} + +// --------------------------------------------------------------------------- +// P1 — Install: --format json produces machine-readable output +// --------------------------------------------------------------------------- + +// TestAgentPluginsInstallFormatJSON verifies that install with --format json +// produces parseable JSON output rather than a human-readable table. +// Covers scenario #36. +func TestAgentPluginsInstallFormatJSON(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "format-json-install-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installBase := t.TempDir() + assert.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installBase, + "--format=json", + ), "install --format json should succeed without error") +} + +// --------------------------------------------------------------------------- +// P1 — List: --check-updates with harness (no network error expected) +// --------------------------------------------------------------------------- + +// TestAgentPluginsListCheckUpdates installs a plugin then runs list +// --check-updates to verify the flag is accepted and produces no error. +// Covers scenario #54. +func TestAgentPluginsListCheckUpdates(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "check-updates-plugin" + version := "1.0.0" + pluginPath := createTestPlugin(t, slug, version) + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + installBase := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installBase, + )) + + // --check-updates requires --harness, not --path; use the published repo for comparison. + assert.NoError(t, runAgentPluginsCmd(t, + "list", + "--repo="+tests.AgentPluginsLocalRepo, + "--check-updates", + ), "list --check-updates --repo should run without error") +} + +// --------------------------------------------------------------------------- +// P2 — List: --format json, --limit, --sort-by, --sort-order flag validation +// --------------------------------------------------------------------------- + +// TestAgentPluginsListFlags exercises list flag combinations that must either +// succeed or produce a descriptive error (never panic). +// Covers scenario #55. +func TestAgentPluginsListFlags(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "list-flags-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + cases := []struct { + name string + args []string + expectError bool + description string + }{ + { + name: "format-json", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--format=json"}, + expectError: false, + description: "--format json with --repo should produce JSON output without error", + }, + { + name: "limit-positive", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--limit=5"}, + expectError: false, + description: "--limit with a positive value should succeed", + }, + { + name: "sort-by-updated", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--sort-by=updated"}, + expectError: false, + description: "--sort-by updated is a valid value for --repo mode", + }, + { + name: "sort-by-invalid", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--sort-by=invalid-field"}, + expectError: true, + description: "--sort-by with unknown field must produce an error", + }, + { + name: "sort-order-desc", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--sort-order=desc"}, + expectError: false, + description: "--sort-order desc should succeed", + }, + { + name: "sort-order-invalid", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--sort-order=sideways"}, + expectError: true, + description: "--sort-order with invalid value must error", + }, + { + name: "check-updates-without-harness", + args: []string{"list", "--repo=" + tests.AgentPluginsLocalRepo, "--check-updates"}, + expectError: false, + description: "--check-updates with --repo should succeed (compares remote versions)", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runAgentPluginsCmd(t, tc.args...) + if tc.expectError { + assert.Error(t, err, tc.description) + } else { + assert.NoError(t, err, tc.description) + } + }) + } +} + +// --------------------------------------------------------------------------- +// P1 — Search: --format json produces parseable output +// --------------------------------------------------------------------------- + +// TestAgentPluginsSearchFormatJSON publishes a plugin with a searchable name +// property then runs search with --format json, confirming the output is valid +// JSON and the slug appears in it. +// Covers scenario #58. +func TestAgentPluginsSearchFormatJSON(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + slug := "search-json-plugin" + pluginPath := createTestPlugin(t, slug, "1.0.0") + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + )) + + assert.NoError(t, runAgentPluginsCmd(t, + "search", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--format=json", + ), "search --format json should succeed without error") +} + +// --------------------------------------------------------------------------- +// P1 — Flag validation: unknown flag on any subcommand → error +// --------------------------------------------------------------------------- + +// TestAgentPluginsUnknownFlag verifies that passing an unrecognised flag to +// any subcommand results in a non-zero exit (error), not a panic or silent +// ignore. +// Covers scenario #71. +func TestAgentPluginsUnknownFlag(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + subcommands := []string{"publish", "install", "update", "delete", "list", "search"} + for _, sub := range subcommands { + t.Run(sub, func(t *testing.T) { + err := runAgentPluginsCmd(t, sub, "--this-flag-does-not-exist=xyz") + assert.Error(t, err, + "subcommand %q must reject unknown flags", sub) + }) + } +} + +// --------------------------------------------------------------------------- +// P1 — CI/CD: condensed pipeline (publish → build-publish → install) +// --------------------------------------------------------------------------- + +// TestAgentPluginsCIPipeline simulates a minimal CI/CD workflow: +// 1. publish with build info flags (--quiet mirrors CI) +// 2. jf rt bp to push build info to Artifactory +// 3. install the same slug to confirm end-to-end availability +// +// Covers scenario #78. +func TestAgentPluginsCIPipeline(t *testing.T) { + initAgentPluginsTest(t) + defer cleanAgentPluginsTest() + + const ( + slug = "ci-pipeline-plugin" + version = "1.0.0" + buildNumber = "1" + ) + + pluginPath := createTestPlugin(t, slug, version) + + // Step 1 — publish with build info, simulating CI (--quiet suppresses prompts). + require.NoError(t, runAgentPluginsCmd(t, + "publish", pluginPath, + "--repo="+tests.AgentPluginsLocalRepo, + "--build-name="+tests.AgentPluginsBuildName, + "--build-number="+buildNumber, + "--quiet", + ), "CI publish step must succeed") + + assertPluginExists(t, slug, version) + + // Step 2 — push build info to Artifactory. + require.NoError(t, artifactoryCli.Exec("bp", tests.AgentPluginsBuildName, buildNumber), + "jf rt bp must succeed after publish") + + _, found, err := tests.GetBuildInfo(serverDetails, tests.AgentPluginsBuildName, buildNumber) + require.NoError(t, err) + require.True(t, found, "build info must be retrievable after bp") + + // Step 3 — install into a scratch directory, simulating a downstream CI job. + installBase := t.TempDir() + require.NoError(t, runAgentPluginsCmd(t, + "install", slug, + "--repo="+tests.AgentPluginsLocalRepo, + "--path="+installBase, + "--quiet", + ), "CI install step must succeed") + + pluginDir := filepath.Join(installBase, slug) + _, err = os.Stat(pluginDir) + assert.NoError(t, err, "installed plugin directory must exist after CI pipeline") +} + +// --------------------------------------------------------------------------- +// Test fixture helpers +// --------------------------------------------------------------------------- + +// createTestPluginWithVersion creates a minimal plugin directory with a +// specific version string (which may be intentionally invalid for error tests). +func createTestPluginWithVersion(t *testing.T, slug, version string) string { + t.Helper() + dir := t.TempDir() + manifest := map[string]string{"name": slug, "version": version, "description": "test"} + data, err := json.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0644)) // #nosec G306 -- test fixture + return dir +} + +// createTestPluginWithSlug creates a minimal plugin directory with a specific +// slug in the manifest (which may be intentionally invalid for error tests). +func createTestPluginWithSlug(t *testing.T, slug, version string) string { + t.Helper() + dir := t.TempDir() + // Write raw JSON to avoid json.Marshal normalising the slug. + raw := fmt.Sprintf(`{"name":%q,"version":%q,"description":"test"}`, slug, version) + require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(raw), 0644)) // #nosec G306 -- test fixture + return dir +}