diff --git a/.github/workflows/agentApmTests.yml b/.github/workflows/agentApmTests.yml new file mode 100644 index 000000000..c2d1df79c --- /dev/null +++ b/.github/workflows/agentApmTests.yml @@ -0,0 +1,102 @@ +name: Agent APM Tests + +on: + workflow_call: + workflow_dispatch: + inputs: + jfrog_url: + description: "External JFrog Platform URL. Leave empty for local Artifactory." + type: string + required: false + default: "" + jfrog_admin_token: + description: "Admin token for external JFrog Platform." + type: string + required: false + default: "" + +jobs: + Agent-APM-Tests: + name: agent-apm ${{ matrix.os.name }} + strategy: + fail-fast: false + matrix: + os: + - name: ubuntu + version: 24.04 + - name: windows + version: 2022 + - name: macos + version: 14 + runs-on: ${{ matrix.os.name }}-${{ matrix.os.version }} + steps: + - name: Skip macOS - JGC-413 + if: matrix.os.name == 'macos' + run: | + echo "::warning::JGC-413 - Skip until artifactory bootstrap in osx is fixed" + exit 0 + + - name: Checkout code + if: matrix.os.name != 'macos' + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + # Safe: this workflow only runs after human approval via the build-gate environment. + allow-unsafe-pr-checkout: true + + - name: Setup FastCI + if: matrix.os.name != 'macos' + uses: jfrog-fastci/fastci@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + fastci_otel_token: ${{ secrets.FASTCI_TOKEN }} + + - name: Setup Go with cache + if: matrix.os.name != 'macos' + uses: jfrog/.github/actions/install-go-with-cache@main + + - name: Install APM (Linux) + if: matrix.os.name == 'ubuntu' + run: | + APM_VERSION="v0.23.1" + OS="linux" + ARCH="x86_64" + curl -sL "https://github.com/microsoft/apm/releases/download/${APM_VERSION}/apm-${OS}-${ARCH}.tar.gz" -o apm.tar.gz + tar -xzf apm.tar.gz + sudo mkdir -p /opt/apm + sudo mv apm-${OS}-${ARCH}/* /opt/apm/ + sudo chmod +x /opt/apm/apm + echo "/opt/apm" >> $GITHUB_PATH + rm -rf apm-${OS}-${ARCH} apm.tar.gz + /opt/apm/apm --version + + - name: Install APM (Windows) + if: matrix.os.name == 'windows' + shell: pwsh + run: | + $APM_VERSION = "v0.28.0" + $OS = "windows" + $ARCH = "x86_64" + $APM_URL = "https://github.com/microsoft/apm/releases/download/${APM_VERSION}/apm-${OS}-${ARCH}.zip" + curl.exe -sL "$APM_URL" -o apm.zip + Expand-Archive -Path apm.zip -DestinationPath . + New-Item -ItemType Directory -Path "C:\tools\apm" -Force | Out-Null + Move-Item -Path "apm-${OS}-${ARCH}\*" -Destination "C:\tools\apm\" -Force + Add-Content -Path $env:GITHUB_PATH -Value "C:\tools\apm" + Remove-Item -Path "apm-${OS}-${ARCH}", "apm.zip" -Recurse -Force + & "C:\tools\apm\apm.exe" --version + + - name: Install local Artifactory + if: matrix.os.name != 'macos' + uses: jfrog/.github/actions/install-local-artifactory@main + with: + RTLIC: ${{ secrets.RTLIC }} + JFROG_URL: ${{ inputs.jfrog_url }} + JFROG_ADMIN_TOKEN: ${{ inputs.jfrog_admin_token }} + RT_CONNECTION_TIMEOUT_SECONDS: ${{ env.RT_CONNECTION_TIMEOUT_SECONDS || '1200' }} + + - name: Run agent apm tests + if: matrix.os.name != 'macos' + run: >- + go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apm + ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} diff --git a/.github/workflows/build-gate.yml b/.github/workflows/build-gate.yml index 53b12e675..b89a98d98 100644 --- a/.github/workflows/build-gate.yml +++ b/.github/workflows/build-gate.yml @@ -45,6 +45,10 @@ jobs: needs: gate uses: ./.github/workflows/agentSkillsTests.yml secrets: inherit + agent-apm: + needs: gate + uses: ./.github/workflows/agentApmTests.yml + secrets: inherit access: needs: gate # OIDC suite: caller must grant id-token so the reusable workflow can request it. @@ -182,6 +186,7 @@ jobs: - frogbot - agent-plugins - agent-skills + - agent-apm - access - artifactory - conan diff --git a/agent_apm_test.go b/agent_apm_test.go new file mode 100644 index 000000000..15d0f656d --- /dev/null +++ b/agent_apm_test.go @@ -0,0 +1,2269 @@ +package main + +import ( + "crypto/md5" // #nosec G501 -- checksum verification against Artifactory's own reported MD5, not security-sensitive + "crypto/sha1" // #nosec G505 -- checksum verification against Artifactory's own reported SHA1, not security-sensitive + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + buildinfo "github.com/jfrog/build-info-go/entities" + artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/spec" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-cli/inttestutils" + "github.com/jfrog/jfrog-cli/utils/tests" + accessServices "github.com/jfrog/jfrog-client-go/access/services" + clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + apmBuildName = "apm-test-build" + dirPerms = 0755 + filePerms = 0644 +) + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns everything written to +// it. apm's own diagnostics (e.g. "HTTP 404 ...") are printed straight to os.Stdout by the +// underlying apm subprocess and never appear in the Go error returned by CLI commands, so +// assertions on that text must inspect captured stdout instead of err.Error(). +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + fnErr := fn() + + require.NoError(t, w.Close()) + os.Stdout = origStdout + + out, readErr := io.ReadAll(r) + require.NoError(t, readErr) + return string(out), fnErr +} + +// computeFileSHA1 and computeFileMD5 mirror apk_test.go's computeFileSHA256 (same package) for +// the other two checksums build-info round-trip tests need to independently verify. +func computeFileSHA1(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) // #nosec G304 -- path is always a test-controlled temp download destination + require.NoError(t, err, "open file for SHA1: %s", path) + defer func() { require.NoError(t, f.Close()) }() + h := sha1.New() // #nosec G401 -- checksum verification, not a security-relevant crypto use + _, err = io.Copy(h, f) + require.NoError(t, err, "compute SHA1 for: %s", path) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func computeFileMD5(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) // #nosec G304 -- path is always a test-controlled temp download destination + require.NoError(t, err, "open file for MD5: %s", path) + defer func() { require.NoError(t, f.Close()) }() + h := md5.New() // #nosec G401 -- checksum verification, not a security-relevant crypto use + _, err = io.Copy(h, f) + require.NoError(t, err, "compute MD5 for: %s", path) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// initApmTest initializes the APM test environment. +func initApmTest(t *testing.T) { + if !*tests.TestApm { + t.Skip("Skipping APM tests. To run APM test add the '-test.apm=true' option.") + } + // Ensure APM is installed + _, err := exec.LookPath("apm") + require.NoError(t, err, "APM must be installed to run APM tests. Install from: https://github.com/microsoft/apm/releases") + // Ensure JFROG_RUN_NATIVE is not set (clean state for non-native tests) + _ = os.Unsetenv("JFROG_RUN_NATIVE") + createJfrogHomeConfig(t, true) + createApmRepository(t) + initApmConfig(t) +} + +// getApmCli returns a CLI configured for APM commands (without "rt" prefix). +// APM commands are: jfrog agent apm ..., not jfrog rt agent apm ... +func getApmCli() *coreTests.JfrogCli { + return coreTests.NewJfrogCli(execMain, "jfrog", "") +} + +// publishApmDependencyPackage publishes a minimal, real APM package to the default registry +// (tests.AgentPackagesLocalRepo) so other tests can declare it as a resolvable dependency +// (via the "owner/name#version" shorthand) and exercise real install/build-info collection. +// packageSpec is "owner/name"; version is the version to publish (e.g. "1.0.0"). +func publishApmDependencyPackage(t *testing.T, packageSpec, version string) { + t.Helper() + publishApmDependencyPackageToRegistry(t, packageSpec, version, tests.AgentPackagesLocalRepo) +} + +// publishApmDependencyPackageToRegistry is publishApmDependencyPackage targeting a specific, +// already-configured registry name (e.g. one of several distinct repos set up via +// "jf setup apm --repo "), instead of always the default tests.AgentPackagesLocalRepo. +func publishApmDependencyPackageToRegistry(t *testing.T, packageSpec, version, registryName string) { + t.Helper() + pubDir, err := os.MkdirTemp("", "apm-dep-publish-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(pubDir) + }() + + require.NoError(t, os.MkdirAll(filepath.Join(pubDir, ".apm", "primitives"), dirPerms)) + _, pkgName, ok := strings.Cut(packageSpec, "/") + require.True(t, ok, "packageSpec must be in owner/name form, got %q", packageSpec) + + apmYaml := fmt.Sprintf(`name: %s +version: %s +license: UNLICENSED +targets: + - claude +primitives: + agents: [] +`, pkgName, version) + require.NoError(t, os.WriteFile(filepath.Join(pubDir, "apm.yml"), []byte(apmYaml), filePerms)) + require.NoError(t, os.WriteFile(filepath.Join(pubDir, ".apm", "primitives", "placeholder.txt"), []byte("placeholder content"), filePerms)) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + clientTestUtils.ChangeDirAndAssert(t, pubDir) + + require.NoError(t, getApmCli().Exec("agent", "apm", "publish", "--package", packageSpec, "--registry", registryName), + "publishing dependency package %s to registry %s should succeed", packageSpec, registryName) +} + +// createApmRepository creates a local APM repository for testing. +func createApmRepository(t *testing.T) { + if !isRepoExist(tests.AgentPackagesLocalRepo) { + repoConfig := tests.GetTestResourcesPath() + tests.AgentPackagesLocalRepositoryConfig + repoConfig, err := tests.ReplaceTemplateVariables(repoConfig, "") + require.NoError(t, err) + execCreateRepoRest(repoConfig, tests.AgentPackagesLocalRepo) + } +} + +// createAgentPackagesRepoWithKey creates an agent-packages local repository whose "key" +// field matches repoName. ReplaceTemplateVariables always substitutes the ${AGENT_PACKAGES_LOCAL_REPO} +// placeholder with the tests.AgentPackagesLocalRepo constant, so for repos with a different name +// we patch the "key" field ourselves after substitution to avoid an Artifactory key/path conflict. +func createAgentPackagesRepoWithKey(t *testing.T, repoName string) { + repoConfig := tests.GetTestResourcesPath() + tests.AgentPackagesLocalRepositoryConfig + repoConfig, err := tests.ReplaceTemplateVariables(repoConfig, "") + require.NoError(t, err) + + content, err := os.ReadFile(repoConfig) + require.NoError(t, err) + patched := strings.Replace(string(content), `"key": "`+tests.AgentPackagesLocalRepo+`"`, `"key": "`+repoName+`"`, 1) + + patchedPath := filepath.Join(filepath.Dir(repoConfig), repoName+"_repository_config.json") + require.NoError(t, os.WriteFile(patchedPath, []byte(patched), filePerms)) // #nosec G703 -- repoName is always one of this test's own hardcoded literals, not external input + + execCreateRepoRest(patchedPath, repoName) +} + +// ensureApmTestProjectExists creates the shared tests.ProjectKey Artifactory project and assigns +// tests.AgentPackagesLocalRepo to it. "--project" scoping on jf commands (e.g. jf rt bp +// --project=X) requires a real Project entity server-side - it's not just a local metadata tag - +// so tests exercising project scoping must provision one first. +// +// Skips (not fails) the calling test when Projects/Access isn't available in the current +// environment: local Artifactory instances used in some CI/test setups don't have it licensed +// or enabled, which is an environment limitation, not a defect in the apm code under test. Same +// graceful-skip pattern as TestApkAdd_ProjectBuildInfoCollection. +func ensureApmTestProjectExists(t *testing.T) { + t.Helper() + accessManager, err := artUtils.CreateAccessServiceManager(serverDetails, false) + if err != nil { + t.Skipf("Skipping project-scoped test - cannot create access manager: %v", err) + } + + // Best-effort: ignore "doesn't exist yet" and any other delete failure alike, since the + // only thing that matters is a clean CreateProject call next. + _ = accessManager.DeleteProject(tests.ProjectKey) + + if err := accessManager.CreateProject(accessServices.ProjectParams{ + ProjectDetails: accessServices.Project{ + DisplayName: "apm test project " + tests.ProjectKey, + ProjectKey: tests.ProjectKey, + }, + }); err != nil { + t.Skipf("Skipping project-scoped test - cannot create project: %v", err) + } + if err := accessManager.AssignRepoToProject(tests.AgentPackagesLocalRepo, tests.ProjectKey, true); err != nil { + t.Skipf("Skipping project-scoped test - cannot assign repo to project: %v", err) + } +} + +// initApmConfig sets up the APM configuration in ~/.apm/config.json via jf setup. +func initApmConfig(t *testing.T) { + // Use jf setup to configure APM (not jf rt setup) + setupCli := coreTests.NewJfrogCli(execMain, "jfrog", "") + err := setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + require.NoError(t, err, "jf setup apm should succeed") +} + +// cleanApmTest cleans up resources after APM tests. +func cleanApmTest(t *testing.T) { + clientTestUtils.UnSetEnvAndAssert(t, coreutils.HomeDir) + deleteSpec := spec.NewBuilder().Pattern(tests.AgentPackagesLocalRepo).BuildSpec() + _, _, err := tests.DeleteFiles(deleteSpec, serverDetails) + require.NoError(t, err, "cleanup should remove test artifacts") + tests.CleanFileSystem() +} + +// createApmTestProject creates a minimal APM project structure with apm.yml. +func createApmTestProject(t *testing.T, projectDir string) { + err := os.MkdirAll(projectDir, dirPerms) + require.NoError(t, err) + + // Create minimal .apm directory + apmDir := filepath.Join(projectDir, ".apm") + err = os.MkdirAll(apmDir, dirPerms) + require.NoError(t, err) + + // Create basic primitives directory + primitivesDir := filepath.Join(apmDir, "primitives") + err = os.MkdirAll(primitivesDir, dirPerms) + require.NoError(t, err) + + // Create apm.yml + apmYamlContent := `version: "1.0.0" +name: test-apm-package +description: Test APM package for e2e testing +license: UNLICENSED +targets: + - claude +primitives: + agents: [] + skills: [] + models: [] + tools: [] +dependencies: + apm: [] + mcp: [] +` + + apmYamlPath := filepath.Join(projectDir, "apm.yml") + err = os.WriteFile(apmYamlPath, []byte(apmYamlContent), filePerms) + require.NoError(t, err) + + // Create a dummy file for packaging + dummyFile := filepath.Join(primitivesDir, "placeholder.txt") + err = os.WriteFile(dummyFile, []byte("placeholder content"), filePerms) + require.NoError(t, err) +} + +// createApmTestProjectWithDependency creates the same minimal project as createApmTestProject, +// but declares depSpec (e.g. "test/dep-pkg#1.0.0") as a real APM dependency. The caller is +// responsible for having already published depSpec's package (see publishApmDependencyPackage) +// so install actually resolves it and produces apm.lock.yaml / build info. +func createApmTestProjectWithDependency(t *testing.T, projectDir, depSpec string) { + createApmTestProject(t, projectDir) + + apmYamlContent := `version: "1.0.0" +name: test-apm-package +description: Test APM package for e2e testing +license: UNLICENSED +targets: + - claude +primitives: + agents: [] + skills: [] + models: [] + tools: [] +dependencies: + apm: + - ` + depSpec + ` +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "apm.yml"), []byte(apmYamlContent), filePerms)) +} + +// fetchPublishedApmBuildInfo publishes the locally-collected build info to Artifactory +// (jf rt bp) and reads it back from the server. +// +// apm's install/publish/update commands only ever call Build.AddArtifacts / +// Build.SavePartialBuildInfo, which write *partial* build-info files under +// /partials/ - they never call Build.SaveBuildInfo to materialize a combined, +// "generated" build info directly under (the file build.GetGeneratedBuildsInfo +// reads). That's consistent with the rest of jfrog-cli's build-info design: jf rt bp +// itself calls Build.ToBuildInfo(), which reads the same partials and assembles the +// final build info at publish time - GetGeneratedBuildsInfo is for package managers whose +// commands call Build.SaveBuildInfo directly (npm, docker, conan, etc.), not for reading +// pre-publish partials. So build.GetGeneratedBuildsInfo(name, number, "") is always +// guaranteed to return zero results for apm and cannot be used to validate its build info +// pre-publish; publish-then-verify-on-server is required. +func fetchPublishedApmBuildInfo(t *testing.T, buildName, buildNumber string) *buildinfo.BuildInfo { + t.Helper() + return fetchPublishedApmBuildInfoInProject(t, buildName, buildNumber, "") +} + +// fetchPublishedApmBuildInfoInProject is fetchPublishedApmBuildInfo scoped to an Artifactory project key. +func fetchPublishedApmBuildInfoInProject(t *testing.T, buildName, buildNumber, projectKey string) *buildinfo.BuildInfo { + t.Helper() + bpArgs := []string{"bp", buildName, buildNumber} + if projectKey != "" { + bpArgs = append(bpArgs, "--project", projectKey) + } + require.NoError(t, artifactoryCli.Exec(bpArgs...), "jf rt bp should succeed") + + published, found, err := tests.GetBuildInfoInProject(serverDetails, buildName, buildNumber, projectKey) + require.NoError(t, err) + require.True(t, found, "published build info should be found on the server") + return &published.BuildInfo +} + +// readLocalApmPartialBuildInfo reads locally-collected build info directly via +// Build.ToBuildInfo(), which assembles it from partial files without touching the server or +// clearing anything - the same mechanism pnpm_test.go/npm_test.go use to validate build info +// collection. Unlike fetchPublishedApmBuildInfo, this must be used for INTERMEDIATE checks +// within a multi-step test: "jf rt bp" calls Build.Clean() after a successful publish, wiping +// local partials for that exact build name/number. Calling fetchPublishedApmBuildInfo (or any +// validate* built on it) more than once for the same build/number silently loses whatever an +// earlier step wrote - confirmed live: a dependency captured after install disappeared from a +// later "has both artifacts and dependencies" check, once an intervening bp call for that same +// build/number had already run and cleared it. Reserve the server round-trip for a single, +// final check per build/number. +func readLocalApmPartialBuildInfo(t *testing.T, buildName, buildNumber string) *buildinfo.BuildInfo { + t.Helper() + buildInfoService := buildUtils.CreateBuildInfoService() + apmBuild, err := buildInfoService.GetOrCreateBuildWithProject(buildName, buildNumber, "") + require.NoError(t, err) + bi, err := apmBuild.ToBuildInfo() + require.NoError(t, err) + return bi +} + +// apmRegistryURL builds the real registry URL for repoName, matching exactly what +// AgentPackagesBaseURL in jfrog-cli-artifactory constructs from serverDetails +// (/api/agentpackages//). A registry declared in apm.yml's own +// registries: block is used by apm as its literal API base URL for that registry - not merely +// matched by host for credential discovery - so it must be this exact form, not just any URL on +// the right host, or apm's own HTTP requests 404/403 against the wrong path. +func apmRegistryURL(repoName string) string { + return strings.TrimSuffix(*tests.JfrogUrl, "/") + "/artifactory/api/agentpackages/" + repoName + "/" +} + +// validateApmBuildInfo publishes and validates the build info collected by an APM command. +func validateApmBuildInfo(t *testing.T, buildName, buildNumber string, expectedArtifacts int) { + buildResult := fetchPublishedApmBuildInfo(t, buildName, buildNumber) + + // Verify build properties + assert.Equal(t, buildName, buildResult.Name) + assert.Equal(t, buildNumber, buildResult.Number) + + // Verify modules exist if artifacts expected + if expectedArtifacts > 0 && len(buildResult.Modules) > 0 { + module := buildResult.Modules[0] + // Verify all artifacts have checksums + for _, artifact := range module.Artifacts { + assert.NotEmpty(t, artifact.Sha256, "Artifact should have SHA256 checksum") + assert.NotEmpty(t, artifact.Path, "Artifact should have path") + } + + // Verify dependencies if present + for _, dep := range module.Dependencies { + assert.NotEmpty(t, dep.Sha256, "Dependency should have SHA256 checksum") + assert.NotEmpty(t, dep.Id, "Dependency should have ID") + } + } +} + +// validateBuildInfoDependencies validates dependencies exist in the published build info +func validateBuildInfoDependencies(t *testing.T, buildName, buildNumber string) { + buildResult := fetchPublishedApmBuildInfo(t, buildName, buildNumber) + require.Len(t, buildResult.Modules, 1, "Build should have at least one module") + + module := buildResult.Modules[0] + require.NotEmpty(t, module.Dependencies, "Dependencies should be present in build info") + + // Dependency checksums come from the same HEAD-based resolution as artifact checksums (see + // resolveChecksumsByHead in jfrog-cli-artifactory), so all three are required here too, not + // merely the ID. + for _, dep := range module.Dependencies { + assert.NotEmpty(t, dep.Id, "Dependency should have ID") + assert.NotEmpty(t, dep.Sha256, "Dependency should have SHA256") + assert.Len(t, dep.Sha1, 40, "Dependency should have a 40 hex-character SHA1") + assert.Len(t, dep.Md5, 32, "Dependency should have a 32 hex-character MD5") + } +} + +// validateBuildInfoArtifacts validates artifacts in the published build info +func validateBuildInfoArtifacts(t *testing.T, buildName, buildNumber string, expectedCount int) { + buildResult := fetchPublishedApmBuildInfo(t, buildName, buildNumber) + require.Len(t, buildResult.Modules, 1, "Build should have at least one module") + + module := buildResult.Modules[0] + require.Len(t, module.Artifacts, expectedCount, "Artifacts count should match expected") + + for _, artifact := range module.Artifacts { + assert.NotEmpty(t, artifact.Path, "Artifact should have path") + assert.NotEmpty(t, artifact.Sha256, "Artifact should have SHA256") + assert.Len(t, artifact.Sha1, 40, "Artifact should have a 40 hex-character SHA1") + assert.Len(t, artifact.Md5, 32, "Artifact should have a 32 hex-character MD5") + } +} + +// validateBuildInfoHasBothArtifactsAndDependencies validates both exist in the published build +// info, with checksums on each - not just presence of the two lists. +func validateBuildInfoHasBothArtifactsAndDependencies(t *testing.T, buildName, buildNumber string) { + buildResult := fetchPublishedApmBuildInfo(t, buildName, buildNumber) + require.Len(t, buildResult.Modules, 1, "Build should have at least one module") + + module := buildResult.Modules[0] + require.NotEmpty(t, module.Dependencies, "Build info should have dependencies") + require.NotEmpty(t, module.Artifacts, "Build info should have artifacts") + + for _, dep := range module.Dependencies { + assert.NotEmpty(t, dep.Sha256, "Dependency should have SHA256") + } + for _, artifact := range module.Artifacts { + assert.NotEmpty(t, artifact.Sha256, "Artifact should have SHA256") + } +} + +// TestApmSetupAndConfig validates APM setup with apm config file persistence (P0: Scenario #1). +// apmRegistryEntry mirrors one entry under ~/.apm/config.json's "registries" map. Default is +// only ever present (and true) on whichever registry "jf setup apm" most recently +// configured - apm's own config command clears it from any previously-default entry, so at most +// one registry has Default == true at a time. +type apmRegistryEntry struct { + URL string `json:"url"` + Token string `json:"token"` + Default bool `json:"default"` +} + +// readApmRegistries parses ~/.apm/config.json's registries map. +func readApmRegistries(t *testing.T) map[string]apmRegistryEntry { + t.Helper() + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + configData, err := os.ReadFile(filepath.Join(homeDir, ".apm", "config.json")) + require.NoError(t, err) + + var config struct { + Registries map[string]apmRegistryEntry `json:"registries"` + } + require.NoError(t, json.Unmarshal(configData, &config)) + return config.Registries +} + +func TestApmSetupAndConfig(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + apmConfigPath := filepath.Join(homeDir, ".apm", "config.json") + + // First setup call (use correct CLI prefix: jfrog, not jfrog rt) + setupCli := coreTests.NewJfrogCli(execMain, "jfrog", "") + err = setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + require.NoError(t, err, "jf setup apm should succeed") + + assert.FileExists(t, apmConfigPath, "APM config file should be created") + + // Verify --repo maps to an actual registry entry (not just "some registries exist"), with a + // URL that references the repo and a token, and that it's the default registry. + registries := readApmRegistries(t) + primary, ok := registries[tests.AgentPackagesLocalRepo] + require.True(t, ok, "registries should contain an entry named after --repo (%s)", tests.AgentPackagesLocalRepo) + assert.Contains(t, primary.URL, tests.AgentPackagesLocalRepo, "registry URL should reference the configured repo") + assert.NotEmpty(t, primary.Token, "registry entry should have a token") + assert.True(t, primary.Default, "the just-configured repo should be the default registry") + + // Second setup call against a DIFFERENT repo should flip the default to it, and clear + // Default from the previously-default entry - proving "default" tracks the most recently + // configured repo, not just whichever was configured first. + // + // ~/.apm/config.json is a real user-global file, not scoped per test, and several other + // tests in this file install without an explicit --registry (relying on default + // resolution) - so restore tests.AgentPackagesLocalRepo as the default before returning, + // regardless of how this test's own assertions turn out. + secondRepo := "apm-setup-config-test-repo" + if !isRepoExist(secondRepo) { + createAgentPackagesRepoWithKey(t, secondRepo) + } + defer deleteRepo(secondRepo) + defer func() { + _ = setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + }() + + err = setupCli.Exec("setup", "apm", "--repo", secondRepo) + require.NoError(t, err, "jf setup apm should succeed against a second, different repo") + + registries = readApmRegistries(t) + second, ok := registries[secondRepo] + require.True(t, ok, "registries should now contain an entry named after the second --repo (%s)", secondRepo) + assert.Contains(t, second.URL, secondRepo, "second registry URL should reference the second repo") + assert.True(t, second.Default, "the most recently configured repo should be the default registry") + + if first, ok := registries[tests.AgentPackagesLocalRepo]; ok { + assert.False(t, first.Default, "the previously-default registry should no longer be marked default") + } + + // Verify idempotency - re-running setup for the same (now non-default) repo should still + // succeed and flip default back to it. + err = setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + require.NoError(t, err, "jf setup apm should be idempotent") + + registries = readApmRegistries(t) + assert.True(t, registries[tests.AgentPackagesLocalRepo].Default, "re-running setup for the primary repo should make it the default again") +} + +// TestApmInstallWithBuildInfo validates `jf agent apm install` with build-info capture (P0: Scenario #13). +func TestApmInstallWithBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/install-bi-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-install-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + // A real, resolvable dependency is required: apm only writes apm.lock.yaml (and thus only + // jfrog-cli only collects build-info) when the project has at least one dependency. + createApmTestProjectWithDependency(t, projectDir, "test/install-bi-dep#1.0.0") + + buildNumber := "101" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Run apm install with build-info capture + err = getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm install should succeed with build-info") + + // Validate build info was created + validateApmBuildInfo(t, apmBuildName, buildNumber, 0) + + // Publish the build info + err = artifactoryCli.Exec("bp", apmBuildName, buildNumber) + require.NoError(t, err, "jf rt bp should succeed") + + // Clean up build info + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmPublishWithBuildInfo validates `jf agent apm publish` with build-info capture (P0: Scenario #3). +func TestApmPublishWithBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-publish-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "102" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Run apm publish with build-info capture + err = getApmCli().Exec("agent", "apm", "publish", "--package", "jfrog/test-apm-pkg", "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm publish should succeed with build-info") + + // Validate build info was created with artifact + validateApmBuildInfo(t, apmBuildName, buildNumber, 1) + + // Publish the build info + err = artifactoryCli.Exec("bp", apmBuildName, buildNumber) + require.NoError(t, err, "jf rt bp should succeed") + + // Verify artifact was uploaded to Artifactory + deleteSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/jfrog/test-apm-pkg/*.zip"). + BuildSpec() + artifacts, _, err := tests.SearchFiles(deleteSpec, serverDetails) + require.NoError(t, err) + assert.NotEmpty(t, artifacts, "Published APM package should be found in repository") + + // Clean up + _, _, _ = tests.DeleteFiles(deleteSpec, serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmPublishArtifactPath validates artifact upload to correct path (P0: Scenario #4). +func TestApmPublishArtifactPath(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-publish-path-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + owner := "acme" + packageName := "my-agent-skill" + err = getApmCli().Exec("agent", "apm", "publish", "--package", fmt.Sprintf("%s/%s", owner, packageName), "--registry", tests.AgentPackagesLocalRepo) + require.NoError(t, err, "jf agent apm publish should succeed") + + // Verify artifact path: //-.zip + searchSpec := spec.NewBuilder(). + Pattern(fmt.Sprintf("%s/%s/%s/*.zip", tests.AgentPackagesLocalRepo, owner, packageName)). + BuildSpec() + artifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + assert.NotEmpty(t, artifacts, "Artifact should be found at expected path: //-.zip") + + // Verify artifact name format. Note: ResultItem.Path is the artifact's *directory* (e.g. + // "acme/my-agent-skill"); the filename itself is a separate field, Name. + if len(artifacts) > 0 { + assert.True(t, + strings.HasPrefix(artifacts[0].Name, packageName+"-") && strings.HasSuffix(artifacts[0].Name, ".zip"), + "Artifact name should follow pattern: -.zip, got %q", artifacts[0].Name) + } + + // Clean up + _, _, _ = tests.DeleteFiles(searchSpec, serverDetails) +} + +// TestApmPublishRequiresPackageFlag validates that --package flag is required (P0: Scenario #23). +func TestApmPublishRequiresPackageFlag(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-publish-no-pkg-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Attempt publish without --package flag + err = getApmCli().Exec("agent", "apm", "publish") + assert.Error(t, err, "jf agent apm publish without --package should fail") + assert.Contains(t, err.Error(), "package", "Error message should mention --package flag") +} + +// TestApmInstallInvalidPackage validates handling of missing/invalid package references (P0: Scenario #15). +func TestApmInstallInvalidPackage(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-invalid-pkg-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + // Create project with invalid dependency + err = os.MkdirAll(filepath.Join(projectDir, ".apm"), 0755) + require.NoError(t, err) + + // APM dependency shorthand is "owner/name#version" (a plain string), resolved against the + // default registry. A nonexistent package fails at resolve time with a 404-style error. + apmYamlContent := `version: "1.0.0" +name: test-with-missing-dep +license: UNLICENSED +targets: + - claude +dependencies: + apm: + - nonexistent/package#1.0.0 +` + apmYamlPath := filepath.Join(projectDir, "apm.yml") + err = os.WriteFile(apmYamlPath, []byte(apmYamlContent), 0644) + require.NoError(t, err) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Attempt install with invalid package. apm's own diagnostics (including the "HTTP 404" + // detail) are printed to stdout by the apm subprocess, not embedded in the Go error, so we + // must capture stdout to assert on them. + output, cmdErr := captureStdout(t, func() error { + return getApmCli().Exec("agent", "apm", "install") + }) + assert.Error(t, cmdErr, "install of a nonexistent package should fail") + assert.True(t, + strings.Contains(output, "404") || strings.Contains(output, "no package"), + "Output should indicate package not found, got: %s", output) +} + +// TestApmAuthEnvVarBehavior validates two distinct env-var-auth scenarios (both via +// APM_REGISTRY_TOKEN_, agent/apm/common/apmenv.go) as subtests sharing one +// initApmTest/cleanApmTest cycle instead of two separate test functions. +func TestApmAuthEnvVarBehavior(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + // The registry name apm actually knows about is the repo key itself ("cli-agent-packages-local"), + // not a literal name "default" - jf setup apm calls ConfigureApmRegistryPersistent(repoName), + // which writes registry..{url,token,default} into ~/.apm/config.json using repoName + // verbatim. apm sanitizes that name into its env var form the same way jf does + // (sanitizeApmEnvName in apmenv.go: uppercase, "-"/"." -> "_"). Using any other name here (e.g. + // the earlier "default") produces an env var apm never looks at for this registry, so a "wrong + // token" set under that name is silently never consulted - confirmed live, this is exactly why + // the wrong-token subtest below kept passing for the wrong reason before this fix. + registryName := tests.AgentPackagesLocalRepo + tokenEnvVar := fmt.Sprintf("APM_REGISTRY_TOKEN_%s", strings.ToUpper(strings.ReplaceAll(registryName, "-", "_"))) + + t.Run("wrong token is honored instead of silently overridden", func(t *testing.T) { + // jf's own BuildApmEnv (agent/apm/common/apmenv.go) always auto-injects + // APM_REGISTRY_TOKEN_ from the configured server before running apm - a plain + // install with the CORRECT token set ourselves would succeed identically whether or not + // jf actually reads our value or silently substitutes its own, so that alone wouldn't + // prove anything. Setting an intentionally WRONG token instead only fails if jf genuinely + // leaves our value alone (injectRegistryCredentialEnv's "respecting existing value" branch) + // instead of overriding it with the correct one - which is exactly what this proves. + // + // (A debug-log assertion on "credential env var already set" was tried here first, but + // log.SetDefaultLogger() - which reads JFROG_CLI_LOG_LEVEL - is only called from + // main(), not execMain(); this test harness invokes execMain() directly in-process, so + // the log level set via os.Setenv here is never actually picked up. Confirmed live: the + // log line never appeared no matter what level was set.) + publishApmDependencyPackage(t, "test/auth-env-wrong-token-dep", "1.0.0") + + // Belt and braces: apm's own docs say an env var token outranks ~/.apm/config.json's + // stored one, but remove the stored token for this registry anyway so there is no valid + // fallback credential at all - the only credential apm can possibly use is the wrong one + // set below. Restored afterward by re-running jf setup apm (initApmConfig), which + // every other test in this file also depends on having a correctly configured registry. + require.NoError(t, exec.Command("apm", "config", "unset", fmt.Sprintf("registry.%s.token", registryName)).Run(), // #nosec G204 -- fixed argv, no user input + "removing the stored registry token should succeed") + defer initApmConfig(t) + + projectDir, err := os.MkdirTemp("", "apm-auth-env-wrong-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + createApmTestProjectWithDependency(t, projectDir, "test/auth-env-wrong-token-dep#1.0.0") + defer setupTestWorkingDirectory(t, projectDir)() + + require.NoError(t, os.Setenv(tokenEnvVar, "definitely-not-a-real-token")) + defer func() { + _ = os.Unsetenv(tokenEnvVar) + }() + + err = getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", "103") + assert.Error(t, err, "install should fail when the pre-set (invalid) token env var is honored instead of silently overridden") + }) + + t.Run("correct token is not exposed in output", func(t *testing.T) { + projectDir := createApmProjectWithYaml(t, getBasicApmYaml()) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + require.NoError(t, os.Setenv(tokenEnvVar, *tests.JfrogAccessToken)) + defer func() { + _ = os.Unsetenv(tokenEnvVar) + }() + + // The token must be usable for auth but never echoed back in apm's own stdout/log output. + output, err := captureStdout(t, func() error { + return getApmCli().Exec("agent", "apm", "install") + }) + require.NoError(t, err, "install should work with env var auth") + assert.NotContains(t, output, *tests.JfrogAccessToken, "access token should not be exposed in command output") + }) +} + +// TestApmMissingCredentials validates that install fails when there is no registry to discover +// at all - not, as the name might suggest, because credentials are generically "missing". apm +// always gets its actual token from jf's own configured server (BuildApmEnv in +// jfrog-cli-artifactory), regardless of ~/.apm/config.json; that file (and apm.yml's own +// registries: block) only supply the registry NAME+URL to route that token through. With +// neither source present, BuildApmEnv fails before credentials are ever considered - confirmed +// here by asserting on its exact error text ("no APM registry found"), not just a non-nil error, +// so this test can't silently start passing for an unrelated reason. +// See TestApmInstallSucceedsWithRegistryDeclaredInApmYml for the complementary case: apm.yml's +// own registries: block is sufficient on its own, even with ~/.apm/config.json absent. +func TestApmMissingCredentials(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-no-creds-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) // apm.yml here declares no registries: block + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Remove ~/.apm/config.json - with apm.yml declaring no registries: block either, this + // leaves BuildApmEnv nothing to discover a registry from. + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + apmConfigPath := filepath.Join(homeDir, ".apm", "config.json") + err = os.Remove(apmConfigPath) + if err != nil && !os.IsNotExist(err) { + require.NoError(t, err) + } + defer initApmConfig(t) // restore ~/.apm/config.json for later tests regardless of outcome + + // Unset any auth env vars + for _, envVar := range os.Environ() { + if strings.Contains(envVar, "APM_REGISTRY") { + key := strings.Split(envVar, "=")[0] + _ = os.Unsetenv(key) + } + } + + // Attempt install with no registry source available. + err = getApmCli().Exec("agent", "apm", "install") + require.Error(t, err, "jf agent apm install without a discoverable registry should fail") + assert.Contains(t, err.Error(), "no APM registry found", + "the failure should specifically be 'no registry found', not some unrelated error") +} + +// TestApmRegistriesDeclaredInApmYml validates that apm.yml's own registries: block (a url: only - +// see manifest.go's ManifestRegistry - matched to jf's configured server by host, via +// discoverMatchingRegistries) is sufficient on its own for registry discovery: with a single +// entry and ~/.apm/config.json entirely absent, and with multiple entries declared at once +// alongside a present config.json. jf still injects the actual token from its own configured +// server (serverDetails) in both cases; apm.yml never carries a token itself, only the name->URL +// mapping that tells jf which registry name to inject that token under. Both cases share one +// initApmTest/cleanApmTest cycle as subtests rather than two separate test functions. +func TestApmRegistriesDeclaredInApmYml(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + cases := []struct { + name string + registryNames []string + removeApmConfig bool + }{ + { + name: "single registry, config.json absent", + registryNames: []string{tests.AgentPackagesLocalRepo}, + removeApmConfig: true, + }, + { + name: "multiple registries, config.json present", + registryNames: []string{"registry-one", "registry-two"}, + }, + } + + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var registriesYaml strings.Builder + for _, name := range tc.registryNames { + _, _ = fmt.Fprintf(®istriesYaml, " %s:\n url: \"%s\"\n", name, apmRegistryURL(tests.AgentPackagesLocalRepo)) + } + apmYaml := fmt.Sprintf(`name: registry-in-yaml-project +version: 1.0.0 +license: UNLICENSED +targets: + - claude +registries: +%sdependencies: + apm: [] +`, registriesYaml.String()) + + projectDir := createApmProjectWithYaml(t, apmYaml) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + if tc.removeApmConfig { + // The registry above must be discoverable purely from apm.yml's own registries: + // block, matched by host to the configured jf server. + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + apmConfigPath := filepath.Join(homeDir, ".apm", "config.json") + removeErr := os.Remove(apmConfigPath) + if removeErr != nil && !os.IsNotExist(removeErr) { + require.NoError(t, removeErr) + } + defer initApmConfig(t) // restore for the next subtest/test regardless of outcome + } + + buildNumber := fmt.Sprintf("20%d", i) + err := getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "install should succeed using apm.yml's own registries: block") + + validateApmBuildInfo(t, apmBuildName, buildNumber, 0) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) + }) + } +} + +// TestApmBuildInfoArtifactMetadata validates artifact metadata (P0: Scenario #6). +func TestApmBuildInfoArtifactMetadata(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-artifact-metadata-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "104" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + err = getApmCli().Exec("agent", "apm", "publish", "--package", "test/artifact-metadata", "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err) + + // Validate build info has complete artifact metadata + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, buildResult.Modules) + + module := buildResult.Modules[0] + require.NotEmpty(t, module.Artifacts, "build info should have an artifact") + for _, artifact := range module.Artifacts { + // Verify metadata fields are present. Checksum correctness (not just presence) is + // covered separately by TestApmChecksumsInBuildInfo's download-and-recompute round trip. + assert.NotEmpty(t, artifact.Path, "Artifact path should be present") + assert.NotEmpty(t, artifact.Type, "Artifact type should be present") + assert.NotEmpty(t, artifact.Sha256, "Artifact SHA256 should be present") + assert.Len(t, artifact.Sha1, 40, "Artifact should have a 40 hex-character SHA1") + assert.Len(t, artifact.Md5, 32, "Artifact should have a 32 hex-character MD5") + } + + // Clean up + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmBuildPropertiesStamping validates build properties on artifacts (P0: Scenario #8). +func TestApmBuildPropertiesStamping(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-props-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "105" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + err = getApmCli().Exec("agent", "apm", "publish", "--package", "jfrog/props-test", "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err) + + // Publish build info + err = artifactoryCli.Exec("bp", apmBuildName, buildNumber) + require.NoError(t, err) + + // Verify build properties were stamped on artifacts + searchSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/jfrog/props-test/*.zip"). + BuildSpec() + artifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + require.NotEmpty(t, artifacts) + + // Verify properties contain build info + artifact := artifacts[0] + assert.NotEmpty(t, artifact.Properties, "Artifact should have properties") + + // Check for build name/number in properties + foundBuildName := false + foundBuildNumber := false + for _, prop := range artifact.Properties { + switch prop.Key { + case "build.name": + foundBuildName = true + assert.Contains(t, prop.Value, apmBuildName) + case "build.number": + foundBuildNumber = true + assert.Contains(t, prop.Value, buildNumber) + } + } + + assert.True(t, foundBuildName, "Artifact should have build.name property") + assert.True(t, foundBuildNumber, "Artifact should have build.number property") + + // Clean up + _, _, _ = tests.DeleteFiles(searchSpec, serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmModuleFlag validates --module flag for custom module names (P1: Scenario #26). +func TestApmModuleFlag(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/module-flag-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-module-flag-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/module-flag-dep#1.0.0") + + buildNumber := "106" + customModule := "custom-apm-module" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + err = getApmCli().Exec("agent", "apm", "install", "--module", customModule, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm install with --module flag should succeed") + + // Validate custom module name in build info + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + var foundModule bool + for _, module := range buildResult.Modules { + if module.Id == customModule { + foundModule = true + break + } + } + assert.True(t, foundModule, fmt.Sprintf("Custom module %s should be present in build info", customModule)) + + // Clean up + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmRoundTripPublishAndInstall validates full round-trip (P1: Scenario #40). +func TestApmRoundTripPublishAndInstall(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + // Create and publish a package + publishProjectDir, err := os.MkdirTemp("", "apm-roundtrip-publish-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(publishProjectDir) + }() + + createApmTestProject(t, publishProjectDir) + + buildNumberPublish := "201" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, publishProjectDir) + + owner := "roundtrip" + pkgName := "test-package" + + // Publish the package + err = getApmCli().Exec("agent", "apm", "publish", "--package", fmt.Sprintf("%s/%s", owner, pkgName), "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumberPublish) + require.NoError(t, err, "jf agent apm publish should succeed") + + // Create a new directory to install from + installProjectDir, err := os.MkdirTemp("", "apm-roundtrip-install-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(installProjectDir) + }() + + // Create a project that depends on the published package + installApmYaml := `version: "1.0.0" +name: test-consumer +description: Consumer of published APM package +license: UNLICENSED +targets: + - claude +dependencies: + apm: + - ` + owner + `/` + pkgName + `#1.0.0 +` + + err = os.MkdirAll(filepath.Join(installProjectDir, ".apm"), 0755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(installProjectDir, "apm.yml"), []byte(installApmYaml), 0644) + require.NoError(t, err) + + clientTestUtils.ChangeDirAndAssert(t, installProjectDir) + + buildNumberInstall := "202" + + // Install the published package + err = getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", buildNumberInstall) + require.NoError(t, err, "jf agent apm install should succeed with published package") + + // Validate both build infos + validateApmBuildInfo(t, apmBuildName, buildNumberPublish, 1) + validateApmBuildInfo(t, apmBuildName, buildNumberInstall, 0) + + // Clean up + searchSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/" + owner + "/" + pkgName + "/*.zip"). + BuildSpec() + _, _, _ = tests.DeleteFiles(searchSpec, serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmChecksumsInBuildInfo validates SHA256 checksums are recorded (P0: Scenario #18). +// TestApmChecksumsInBuildInfo validates that build info's checksums are not merely present with +// the right format, but actually correct. A well-formed-but-wrong checksum (e.g. from a bug that +// happens to produce a same-shaped value) would pass a presence/length-only check, so this +// downloads the published artifact back from Artifactory and independently recomputes SHA256, +// SHA1, and MD5 locally, then asserts build info's reported values match exactly - the same +// round-trip principle as apk_test.go's TestApkUpload_ChecksumRoundTrip, extended to cross-check +// against build info's own claims rather than only comparing two local files. +func TestApmChecksumsInBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-checksums-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "107" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + owner, pkgName := "test", "checksums" + err = getApmCli().Exec("agent", "apm", "publish", "--package", owner+"/"+pkgName, "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err) + + // Get build info. Artifactory always computes and returns all three checksums together for + // a stored artifact (the HEAD lookup apm's checksum resolution uses - see + // resolveChecksumsByHead in jfrog-cli-artifactory - reads X-Checksum-Sha1/Sha256/Md5 off the + // same response), so all three are required, not merely "present if available". + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, buildResult.Modules, "build info should have a module") + + module := buildResult.Modules[0] + require.NotEmpty(t, module.Artifacts, "build info should have an artifact") + + // Download the published artifact and independently recompute its checksums. + downloadDir, err := os.MkdirTemp("", "apm-checksums-download-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(downloadDir) + }() + artifactPattern := fmt.Sprintf("%s/%s/%s/*.zip", tests.AgentPackagesLocalRepo, owner, pkgName) + require.NoError(t, artifactoryCli.Exec("dl", artifactPattern, downloadDir+"/", "--flat")) + + downloadedFiles, err := os.ReadDir(downloadDir) + require.NoError(t, err) + require.Len(t, downloadedFiles, 1, "exactly one artifact should have been downloaded") + downloadedPath := filepath.Join(downloadDir, downloadedFiles[0].Name()) + + actualSha256 := computeFileSHA256(t, downloadedPath) + actualSha1 := computeFileSHA1(t, downloadedPath) + actualMd5 := computeFileMD5(t, downloadedPath) + + for _, artifact := range module.Artifacts { + assert.Equal(t, actualSha256, artifact.Sha256, "build info SHA256 should match the actual downloaded artifact") + assert.Equal(t, actualSha1, artifact.Sha1, "build info SHA1 should match the actual downloaded artifact") + assert.Equal(t, actualMd5, artifact.Md5, "build info MD5 should match the actual downloaded artifact") + } + + // Clean up + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmProjectFlag validates --project flag for project isolation (P1: Scenario #27). +func TestApmProjectFlag(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + ensureApmTestProjectExists(t) + publishApmDependencyPackage(t, "test/project-flag-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-project-flag-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/project-flag-dep#1.0.0") + + buildNumber := "108" + projectKey := tests.ProjectKey + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + err = getApmCli().Exec("agent", "apm", "install", "--project", projectKey, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm install with --project flag should succeed") + + // Validate build info is scoped to project + buildResult := fetchPublishedApmBuildInfoInProject(t, apmBuildName, buildNumber, projectKey) + require.NotNil(t, buildResult, "Build should be found when queried with correct project key") + + // Clean up + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmUpdateWithBuildInfo validates `jf agent apm update` with build-info (P1: Scenario #16). +func TestApmUpdateWithBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/update-bi-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-update-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/update-bi-dep#1.0.0") + + buildNumber := "109" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // First, install to have a lockfile + err = getApmCli().Exec("agent", "apm", "install") + require.NoError(t, err) + + // Then update with build-info capture. --yes is required: apm update shows a + // confirmation plan and exits 1 without it, even in CI/non-interactive shells. + err = getApmCli().Exec("agent", "apm", "update", "--yes", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm update should succeed with build-info") + + // Validate build info was created + validateApmBuildInfo(t, apmBuildName, buildNumber, 0) + + // Clean up + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmNativeFlags validates native APM flags with -- escape (P1: Scenario #28). +func TestApmNativeFlags(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-native-flags-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // --dry-run passed directly (not via a "--" escape). Captures stdout to confirm apm's own + // output actually acknowledges dry-run mode - proving the flag reached apm as a real, + // recognized flag rather than being silently swallowed or misinterpreted - which + // TestApmDryRunNoArtifacts (server-side non-upload only) doesn't check. + output, err := captureStdout(t, func() error { + return getApmCli().Exec("agent", "apm", "publish", "--package", "test/native-flags", "--registry", tests.AgentPackagesLocalRepo, "--dry-run") + }) + require.NoError(t, err, "jf agent apm publish with --dry-run should succeed") + assert.True(t, strings.Contains(strings.ToLower(output), "dry-run") || strings.Contains(strings.ToLower(output), "would publish"), + "apm's own output should confirm dry-run mode was engaged, got: %s", output) + + // Verify no artifact was uploaded for dry-run + searchSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/test/native-flags/*.zip"). + BuildSpec() + artifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + assert.Empty(t, artifacts, "dry-run should not create artifacts in repository") +} + +// TestApmBuildInfoRead validates a published apm build-info can be read back from Artifactory +// (P0: Scenario #5). There is no "jf rt bi" read command - jf's build-info commands are all +// write-side (build-publish/build-collect-env/etc.); reading a published build back is done via +// the REST API, which is what tests.GetBuildInfo (used throughout this file) wraps. +func TestApmBuildInfoRead(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-bi-read-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "110" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Create build info first + err = getApmCli().Exec("agent", "apm", "publish", "--package", "test/bi-read", "--registry", tests.AgentPackagesLocalRepo, "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err) + + // Publish to Artifactory + err = artifactoryCli.Exec("bp", apmBuildName, buildNumber) + require.NoError(t, err) + + // Read the published build info back from Artifactory + published, found, err := tests.GetBuildInfo(serverDetails, apmBuildName, buildNumber) + require.NoError(t, err, "reading the published build info should succeed") + require.True(t, found, "published build info should be found on the server") + assert.Equal(t, apmBuildName, published.BuildInfo.Name) + assert.Equal(t, buildNumber, published.BuildInfo.Number) + + // Clean up + _, _, _ = tests.DeleteFiles( + spec.NewBuilder().Pattern(tests.AgentPackagesLocalRepo+"/test/bi-read/*.zip").BuildSpec(), + serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmIntegrationFullPipeline validates end-to-end install->publish->build-publish, checking +// real state after each step rather than only exit codes. +// TestApmInstallAndPublishWithBuildInfoComplete covers the same shape without a dependency; this +// is the one with both a real dependency AND a publish in a single pipeline. +func TestApmIntegrationFullPipeline(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/e2e-pipeline-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-e2e-pipeline-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/e2e-pipeline-dep#1.0.0") + + buildName := "apm-e2e-pipeline" + buildNumber := "300" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Step 1: Install (with build-info) - verify the dependency was captured LOCALLY (no server + // round-trip yet). "jf rt bp" clears local partials for this exact build name/number after a + // successful publish (Build.Clean() in build-info-go), so checking via the server here would + // erase this step's dependency before Step 2's artifact could join it in one combined check. + err = getApmCli().Exec("agent", "apm", "install", "--build-name", buildName, "--build-number", buildNumber) + require.NoError(t, err, "Step 1: Install should succeed") + localAfterInstall := readLocalApmPartialBuildInfo(t, buildName, buildNumber) + require.NotEmpty(t, localAfterInstall.Modules, "locally-collected build info should have a module after install") + assert.NotEmpty(t, localAfterInstall.Modules[0].Dependencies, "locally-collected build info should have the dependency after install") + + // Step 2: Publish (with build-info) - the dependency (Step 1) and the new artifact are both + // still in local partials at this point (no bp call has run yet for this build/number), so + // this first server round-trip sees both together. + err = getApmCli().Exec("agent", "apm", "publish", "--package", "e2e/pipeline", "--registry", tests.AgentPackagesLocalRepo, "--build-name", buildName, "--build-number", buildNumber) + require.NoError(t, err, "Step 2: Publish should succeed") + validateBuildInfoHasBothArtifactsAndDependencies(t, buildName, buildNumber) + + // Step 3: Verify the published package actually landed in Artifactory. (Build info was + // already published as part of Step 2's check above; a second "jf rt bp" here would just + // republish an empty build, since Clean() already cleared local partials.) + searchSpec := spec.NewBuilder().Pattern(tests.AgentPackagesLocalRepo + "/e2e/pipeline/*.zip").BuildSpec() + artifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + require.NotEmpty(t, artifacts, "published package should be found in Artifactory") + + // Clean up + _, _, _ = tests.DeleteFiles(searchSpec, serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ============================================================================ +// GAP ANALYSIS TESTS - Registry Configuration & Dependencies +// ============================================================================ + +// createApmProjectWithYaml creates a test project directory with apm.yml content. +func createApmProjectWithYaml(t *testing.T, yamlContent string) string { + projectDir, err := os.MkdirTemp("", "apm-test-*") + require.NoError(t, err) + + err = os.MkdirAll(filepath.Join(projectDir, ".apm", "primitives"), dirPerms) + require.NoError(t, err) + + apmYmlPath := filepath.Join(projectDir, "apm.yml") + err = os.WriteFile(apmYmlPath, []byte(yamlContent), filePerms) + require.NoError(t, err) + + return projectDir +} + +// setupTestWorkingDirectory saves current directory and changes to projectDir with defer cleanup. +func setupTestWorkingDirectory(t *testing.T, projectDir string) func() { + wd, err := os.Getwd() + require.NoError(t, err) + clientTestUtils.ChangeDirAndAssert(t, projectDir) + return func() { + clientTestUtils.ChangeDirAndAssert(t, wd) + } +} + +// TestApmBuildFlagsRequired validates both build-name and build-number are required together +func TestApmBuildFlagsRequired(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir := createApmProjectWithYaml(t, getBasicApmYaml()) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + // Test missing build-number + err := getApmCli().Exec("agent", "apm", "install", "--build-name", "test-build") + assert.Error(t, err, "Should error when build-number missing but build-name provided") + + // Test missing build-name + err = getApmCli().Exec("agent", "apm", "install", "--build-number", "1") + assert.Error(t, err, "Should error when build-name missing but build-number provided") +} + +// TestApmInstallWithDependenciesInBuildInfo validates dependencies captured in build info +func TestApmInstallWithDependenciesInBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/install-with-deps-bi", "1.0.0") + + projectDir := createProjectWithDependencies(t, "app-with-deps", []string{"test/install-with-deps-bi#1.0.0"}) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "400" + err := runApmInstall(buildNumber) + require.NoError(t, err, "install should succeed") + + validateBuildInfoDependencies(t, apmBuildName, buildNumber) + deleteBuildInfo() +} + +// TestApmPublishWithArtifactsInBuildInfo validates artifacts captured in build info +func TestApmPublishWithArtifactsInBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir := createApmProjectWithYaml(t, getBasicApmYaml()) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "401" + err := runApmPublish("test/artifacts-demo", apmBuildName, buildNumber) + require.NoError(t, err, "publish should succeed") + + validateBuildInfoArtifacts(t, apmBuildName, buildNumber, 1) + _ = deleteArtifacts(tests.AgentPackagesLocalRepo + "/test/artifacts-demo/*.zip") + deleteBuildInfo() +} + +// TestApmBuildInfoWithArtifactsAndDependencies validates both artifacts and dependencies +func TestApmBuildInfoWithArtifactsAndDependencies(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/complete-app-dep", "1.0.0") + + projectDir := createProjectWithDependencies(t, "complete-app", []string{"test/complete-app-dep#1.0.0"}) + defer func() { + _ = os.RemoveAll(projectDir) + }() + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "402" + + // Step 1: Install (captures dependencies) + err := runApmInstall(buildNumber) + require.NoError(t, err) + + // Step 2: Publish (adds artifacts) + err = runApmPublish("complete/demo", apmBuildName, buildNumber) + require.NoError(t, err) + + // Validate both exist + validateBuildInfoHasBothArtifactsAndDependencies(t, apmBuildName, buildNumber) + + _ = deleteArtifacts(tests.AgentPackagesLocalRepo + "/complete/demo/*.zip") + deleteBuildInfo() +} + +// TestApmUpdateWithVersionChange validates update captures a new dependency version in build info. +// A bare "#1.0.0" pin is exact and apm update never moves it; only a semver range like "^1.0.0" +// is a floating constraint update can re-resolve, so this uses "^1.0.0" and republishes the +// dependency at 1.0.1 in between install and update (matching the documented apmbughunt flow). +func TestApmUpdateWithVersionChange(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/version-change-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-update-version-test-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + createApmTestProjectWithDependency(t, projectDir, "test/version-change-dep#^1.0.0") + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "403" + + // Step 1: Install at 1.0.0 + err = runApmInstall(buildNumber) + require.NoError(t, err, "install should succeed") + + installResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, installResult.Modules, "install build info should have a module") + assert.Contains(t, installResult.Modules[0].Dependencies[0].Id, "1.0.0", "install should resolve the dependency at 1.0.0") + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) + + // Bump and republish the dependency so update has something new to pick up. + publishApmDependencyPackage(t, "test/version-change-dep", "1.0.1") + + // Step 2: Update should re-resolve the floating range to 1.0.1 + err = runApmUpdate(apmBuildName, buildNumber) + require.NoError(t, err, "update should succeed") + + updateResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, updateResult.Modules, "update build info should have a module") + assert.Contains(t, updateResult.Modules[0].Dependencies[0].Id, "1.0.1", "update should resolve the dependency at 1.0.1") + + deleteBuildInfo() +} + +// TestApmAuthWithoutEnvVarSucceeds validates the common, default case every other auth test in +// this file deliberately sets an env var to test around: install, publish, and update must all +// succeed with NO APM_REGISTRY_* env var set at all, relying purely on jf's own automatic +// credential injection (BuildApmEnv/injectRegistryCredentialEnv in jfrog-cli-artifactory) from +// its configured server. +func TestApmAuthWithoutEnvVarSucceeds(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + // Ensure no leftover APM_REGISTRY_* env var from a prior test in this process interferes. + for _, envVar := range os.Environ() { + if strings.HasPrefix(envVar, "APM_REGISTRY_") { + _ = os.Unsetenv(strings.SplitN(envVar, "=", 2)[0]) + } + } + + publishApmDependencyPackage(t, "test/no-env-var-auth-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-no-env-var-auth-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/no-env-var-auth-dep#1.0.0") + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + buildNumber := "112" + require.NoError(t, getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", buildNumber), + "install should succeed with no APM_REGISTRY_* env var set") + require.NoError(t, getApmCli().Exec("agent", "apm", "publish", "--package", "test/no-env-var-auth-pkg", "--registry", tests.AgentPackagesLocalRepo), + "publish should succeed with no APM_REGISTRY_* env var set") + require.NoError(t, getApmCli().Exec("agent", "apm", "update", "--yes"), + "update should succeed with no APM_REGISTRY_* env var set") + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmCommandsFailWithoutJfServerConfig validates that removing jf's own server +// configuration entirely (not just APM_REGISTRY_* env vars or ~/.apm/config.json) causes +// install/publish/update to fail, since jf itself has nothing to build credentials from - +// confirming BuildApmEnv's credential injection genuinely depends on jf's own configured server, +// not some other fallback. Restores the "default" server config afterward unconditionally +// (regardless of how this test's own assertions turn out): every other test in this file, and +// the whole test binary, depends on it existing. +func TestApmCommandsFailWithoutJfServerConfig(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/no-server-config-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-no-server-config-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/no-server-config-dep#1.0.0") + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Remove the "default" jf server config entirely. Restore it unconditionally afterward - + // every other test in this file depends on it existing. + configCli := coreTests.NewJfrogCli(execMain, "jfrog config", "") + require.NoError(t, configCli.Exec("rm", "default", "--quiet"), "removing the default server config should succeed") + defer createJfrogHomeConfig(t, true) + + assert.Error(t, getApmCli().Exec("agent", "apm", "install"), "install should fail without a configured jf server") + assert.Error(t, getApmCli().Exec("agent", "apm", "publish", "--package", "test/no-server-config-pkg"), "publish should fail without a configured jf server") + assert.Error(t, getApmCli().Exec("agent", "apm", "update", "--yes"), "update should fail without a configured jf server") +} + +// TestApmMixedRegistryDependenciesInOneInstall validates that a SINGLE install can resolve +// dependencies from two DIFFERENT registries at once - one dependency from apm-registry-1 +// (non-default at install time), another from apm-registry-2 (the default, since +// "jf setup apm --repo X" makes the most-recently-configured repo the default and this +// test configures repos[1] last) - both declared in the same apm.yml via the object-form +// dependency's explicit "registry:" field, confirmed live (a local, parse-only apm install +// dry-run against unreachable ports) to be the real schema: "id: owner/name" + +// "registry: " resolves against exactly the named registry. Covers both the +// non-default-registry and default-registry cases via its two dependencies, so a separate +// single-dependency "different registry" test would only be a strict subset of this one. +func TestApmMixedRegistryDependenciesInOneInstall(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + repos := []string{"apm-registry-1", "apm-registry-2"} + for _, repoName := range repos { + if !isRepoExist(repoName) { + createAgentPackagesRepoWithKey(t, repoName) + } + } + defer func() { + for _, repoName := range repos { + deleteRepo(repoName) + } + }() + + setupCli := coreTests.NewJfrogCli(execMain, "jfrog", "") + for _, repoName := range repos { + err := setupCli.Exec("setup", "apm", "--repo", repoName) + require.NoError(t, err, "setup should succeed for repo %s", repoName) + } + defer func() { + _ = setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + }() + + // Publish one dependency to each registry. + owner := "test" + pkgA, pkgB := "mixed-registry-dep-a", "mixed-registry-dep-b" + publishApmDependencyPackageToRegistry(t, owner+"/"+pkgA, "1.0.0", repos[0]) + publishApmDependencyPackageToRegistry(t, owner+"/"+pkgB, "1.0.0", repos[1]) + + // Consumer project depends on both, each via the object-form dependency's explicit registry: + // field naming its own, different registry. + projectDir, err := os.MkdirTemp("", "apm-mixed-registry-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, ".apm", "primitives"), dirPerms)) + apmYaml := fmt.Sprintf(`name: mixed-registry-consumer +version: 1.0.0 +license: UNLICENSED +targets: + - claude +dependencies: + apm: + - id: %s/%s + version: "1.0.0" + registry: %s + - id: %s/%s + version: "1.0.0" + registry: %s +`, owner, pkgA, repos[0], owner, pkgB, repos[1]) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "apm.yml"), []byte(apmYaml), filePerms)) + + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "405" + err = runApmInstall(buildNumber) + require.NoError(t, err, "install should resolve both dependencies, each from its own distinct registry") + + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, buildResult.Modules, "build info should have a module") + module := buildResult.Modules[0] + require.Len(t, module.Dependencies, 2, "both mixed-registry dependencies should be captured") + + var foundA, foundB bool + for _, dep := range module.Dependencies { + switch { + case strings.Contains(dep.Id, pkgA): + foundA = true + assert.NotEmpty(t, dep.Sha256, "dependency from registry 1 should have a SHA256 checksum") + case strings.Contains(dep.Id, pkgB): + foundB = true + assert.NotEmpty(t, dep.Sha256, "dependency from registry 2 should have a SHA256 checksum") + } + } + assert.True(t, foundA, "dependency published to %s should be present in build info", repos[0]) + assert.True(t, foundB, "dependency published to %s should be present in build info", repos[1]) + + deleteBuildInfo() +} + +// getBasicApmYaml returns basic APM YAML +func getBasicApmYaml() string { + return createApmYaml("test-app", "1.0.0", nil) +} + +// createApmYaml creates customizable APM YAML with parameters. apmDeps are real APM dependency +// specs in "owner/name#version" shorthand (see publishApmDependencyPackage); an empty slice +// yields an empty "apm: []" dependency list. Registries are never declared here - they're +// configured globally via "jf setup apm", not per-project. +func createApmYaml(name, version string, apmDeps []string) string { + depsSection := " apm: []\n" + if len(apmDeps) > 0 { + var b strings.Builder + b.WriteString(" apm:\n") + for _, dep := range apmDeps { + _, _ = fmt.Fprintf(&b, " - %s\n", dep) + } + depsSection = b.String() + } + + return fmt.Sprintf(`name: %s +version: %s +license: UNLICENSED +targets: + - claude +primitives: + agents: [] +dependencies: +%s`, name, version, depsSection) +} + +// createProjectWithDependencies creates a project directory with specified dependencies +func createProjectWithDependencies(t *testing.T, name string, deps []string) string { + apmYaml := createApmYaml(name, "1.0.0", deps) + return createApmProjectWithYaml(t, apmYaml) +} + +// runApmInstall runs install command with optional build info +func runApmInstall(buildNumber string) error { + args := []string{"agent", "apm", "install"} + if buildNumber != "" { + args = append(args, "--build-name", apmBuildName, "--build-number", buildNumber) + } + return getApmCli().Exec(args...) +} + +// runApmPublish runs publish command with optional build info. --registry is passed +// explicitly since publish (unlike install) refuses to guess when more than one registry +// happens to be configured in ~/.apm/config.json (a real risk on any shared machine/CI runner). +func runApmPublish(packagePath, buildName, buildNumber string) error { + args := []string{"agent", "apm", "publish"} + if packagePath != "" { + args = append(args, "--package", packagePath, "--registry", tests.AgentPackagesLocalRepo) + } + if buildName != "" && buildNumber != "" { + args = append(args, "--build-name", buildName, "--build-number", buildNumber) + } + return getApmCli().Exec(args...) +} + +// runApmUpdate runs update command with optional build info. --yes is required: apm update +// shows a confirmation plan and exits 1 without it, even in CI/non-interactive shells. +func runApmUpdate(buildName, buildNumber string) error { + args := []string{"agent", "apm", "update", "--yes"} + if buildName != "" && buildNumber != "" { + args = append(args, "--build-name", buildName, "--build-number", buildNumber) + } + return getApmCli().Exec(args...) +} + +// deleteBuildInfo deletes build info from Artifactory +func deleteBuildInfo() { + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// deleteArtifacts deletes artifacts from repository +func deleteArtifacts(pattern string) error { + spec := spec.NewBuilder().Pattern(pattern).BuildSpec() + _, _, err := tests.DeleteFiles(spec, serverDetails) + return err +} + +// TestApmRegistryPrecedenceDefaultFallback validates that apm.yml's own "registries: default: +// " sibling key (a real, distinct field from any per-registry "default" flag in +// ~/.apm/config.json - see manifest.go's ManifestRegistries.Default / its custom UnmarshalYAML) +// controls which registry a BARE, no-explicit-registry dependency ("owner/name#version") +// resolves against. Confirmed live with a local, parse/resolve-only apm install against two +// unreachable ports: the bare dependency routed to whichever port apm.yml's own default: key +// named, not the first-declared entry - so this publishes a real package to only the SECOND of +// two repos and asserts the bare-shorthand dependency still resolves successfully, which is only +// possible if apm.yml's default: is actually being honored. +func TestApmRegistryPrecedenceDefaultFallback(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + repos := []string{"apm-registry-1", "apm-registry-2"} + for _, repoName := range repos { + if !isRepoExist(repoName) { + createAgentPackagesRepoWithKey(t, repoName) + } + } + defer func() { + for _, repoName := range repos { + deleteRepo(repoName) + } + }() + + setupCli := coreTests.NewJfrogCli(execMain, "jfrog", "") + for _, repoName := range repos { + err := setupCli.Exec("setup", "apm", "--repo", repoName) + require.NoError(t, err, "setup should succeed for repo %s", repoName) + } + defer func() { + _ = setupCli.Exec("setup", "apm", "--repo", tests.AgentPackagesLocalRepo) + }() + + // Publish a real package to the SECOND repo only. + owner, pkgName := "test", "default-fallback-dep" + publishApmDependencyPackageToRegistry(t, owner+"/"+pkgName, "1.0.0", repos[1]) + + // apm.yml declares both repos as named registries, with its own default: pointing at the + // second one. The dependency below uses the bare shorthand (no explicit registry: field), so + // it can only resolve correctly if apm.yml's own default: is actually being honored. + projectDir, err := os.MkdirTemp("", "apm-registry-precedence-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, ".apm", "primitives"), dirPerms)) + apmYaml := fmt.Sprintf(`name: registry-precedence-consumer +version: 1.0.0 +license: UNLICENSED +targets: + - claude +registries: + %s: + url: "%s" + %s: + url: "%s" + default: %s +dependencies: + apm: + - %s/%s#1.0.0 +`, repos[0], apmRegistryURL(repos[0]), repos[1], apmRegistryURL(repos[1]), repos[1], owner, pkgName) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "apm.yml"), []byte(apmYaml), filePerms)) + + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "201" + err = getApmCli().Exec("agent", "apm", "install", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "install should resolve the bare-shorthand dependency via apm.yml's own registries.default: precedence") + + validateBuildInfoDependencies(t, apmBuildName, buildNumber) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmPublishWithDependencyMetadata validates publish captures dependency metadata (P0: Scenario #7). +func TestApmPublishWithDependencyMetadata(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + apmYaml := `name: app-with-deps +version: 1.0.0 +description: App with explicit dependencies +license: UNLICENSED +targets: + - claude +primitives: + agents: [] +dependencies: + apm: [] +` + + projectDir := createApmProjectWithYaml(t, apmYaml) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + dummyFile := filepath.Join(projectDir, ".apm", "primitives", "skill.json") + err := os.WriteFile(dummyFile, []byte(`{"type": "agent"}`), filePerms) + require.NoError(t, err) + + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "202" + // Publish should capture dependency metadata + err = getApmCli().Exec("agent", "apm", "publish", "--package", "test/app-with-deps", "--registry", tests.AgentPackagesLocalRepo, + "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "publish should succeed with dependencies") + + // Validate build info includes dependency metadata + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, buildResult.Modules) + + module := buildResult.Modules[0] + assert.NotEmpty(t, module.Artifacts, "Should have artifact metadata") + + // Clean up + _, _, _ = tests.DeleteFiles( + spec.NewBuilder().Pattern(tests.AgentPackagesLocalRepo+"/test/app-with-deps/*.zip").BuildSpec(), + serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmUpdateChangesLockfile validates update behavior with dependencies (P1: Scenario #16). +func TestApmUpdateChangesLockfile(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + // A real dependency is required: apm only writes apm.lock.yaml when the project has at + // least one dependency to resolve. + publishApmDependencyPackage(t, "test/update-lock-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-update-lock-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/update-lock-dep#1.0.0") + + defer setupTestWorkingDirectory(t, projectDir)() + + buildNumber := "203" + // First install to create initial lockfile + err = getApmCli().Exec("agent", "apm", "install") + require.NoError(t, err) + + // Verify lockfile created + lockfilePath := filepath.Join(projectDir, "apm.lock.yaml") + assert.FileExists(t, lockfilePath, "apm.lock.yaml should exist after install") + + // Update with build-info. --yes is required: apm update shows a confirmation plan and + // exits 1 without it, even in CI/non-interactive shells. + err = getApmCli().Exec("agent", "apm", "update", "--yes", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "update should succeed") + + // Verify lockfile still exists (update should maintain it) + assert.FileExists(t, lockfilePath, "apm.lock.yaml should still exist after update") + + validateApmBuildInfo(t, apmBuildName, buildNumber, 0) + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} + +// TestApmFrozenModeWithDependencies validates frozen mode works with dependencies (P1: Scenario #14). +func TestApmFrozenModeWithDependencies(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/frozen-mode-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-frozen-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProjectWithDependency(t, projectDir, "test/frozen-mode-dep#1.0.0") + + defer setupTestWorkingDirectory(t, projectDir)() + + // First install to create lockfile + err = getApmCli().Exec("agent", "apm", "install") + require.NoError(t, err) + + // Frozen install should succeed (lockfile exists and is up-to-date). --frozen must be + // passed directly, not after a "--" escape: apm parses anything after "--" as a + // positional package argument, not a flag (see TestApmNativeFlags for the same bug). + err = getApmCli().Exec("agent", "apm", "install", "--frozen") + require.NoError(t, err, "frozen install should succeed with existing lockfile") +} + +// TestApmInstallAndPublishWithBuildInfoComplete validates complete install→publish workflow (P1: Scenario #50). +func TestApmInstallAndPublishWithBuildInfoComplete(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-complete-flow-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildName := "apm-complete-flow" + buildNumber := "204" + + defer setupTestWorkingDirectory(t, projectDir)() + + // Step 1: Install with build-info + err = getApmCli().Exec("agent", "apm", "install", "--build-name", buildName, "--build-number", buildNumber) + require.NoError(t, err, "install with build-info should succeed") + + validateApmBuildInfo(t, buildName, buildNumber, 0) + + // Step 2: Publish with build-info + err = getApmCli().Exec("agent", "apm", "publish", "--package", "complete/workflow", "--registry", tests.AgentPackagesLocalRepo, + "--build-name", buildName, "--build-number", buildNumber) + require.NoError(t, err, "publish with build-info should succeed") + + validateApmBuildInfo(t, buildName, buildNumber, 1) + + // Step 3: Publish build info to Artifactory + err = artifactoryCli.Exec("bp", buildName, buildNumber) + require.NoError(t, err, "build-info publish should succeed") + + // Clean up + _, _, _ = tests.DeleteFiles( + spec.NewBuilder().Pattern(tests.AgentPackagesLocalRepo+"/complete/workflow/*.zip").BuildSpec(), + serverDetails) + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// TestApmDryRunNoArtifacts validates --dry-run doesn't upload (P1: Scenario #28). +func TestApmDryRunNoArtifacts(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + projectDir, err := os.MkdirTemp("", "apm-dryrun-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + defer setupTestWorkingDirectory(t, projectDir)() + + // Dry-run publish should not upload artifacts + err = getApmCli().Exec("agent", "apm", "publish", "--package", "dryrun/test", "--registry", tests.AgentPackagesLocalRepo, "--dry-run") + require.NoError(t, err, "dry-run publish should succeed") + + // Verify nothing was uploaded + searchSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/dryrun/test/*.zip"). + BuildSpec() + artifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + assert.Empty(t, artifacts, "dry-run should not create artifacts in repository") +} + +// TestApmNativeCliWorksWithJfSetupCredentials validates that once "jf setup apm" has run, +// the native apm binary can be invoked directly - bypassing "jf agent apm ..." entirely, with no +// build-name/build-number, no build-info collection at all - and still authenticate +// successfully. jf setup apm persists credentials into ~/.apm/config.json; that's a +// different mechanism from BuildApmEnv's APM_REGISTRY_TOKEN_ env-var injection, which only +// happens when jf itself invokes apm as a subprocess. A user running the plain "apm" command in +// their own shell gets none of that env-var wiring, so this test strips any leftover +// APM_REGISTRY_* env vars first, to prove config.json alone is sufficient. +// +// Exit code from apm is not enough evidence either way, so both halves check real server state: +// publish is verified by searching Artifactory for the uploaded artifact (not just that apm +// returned 0), and install is verified by asserting the resulting apm.lock.yaml actually +// references the package and version that was just published (not just that a lockfile exists). +func TestApmNativeCliWorksWithJfSetupCredentials(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + // initApmTest already ran "jf setup apm --repo " (via initApmConfig), writing + // credentials into ~/.apm/config.json. Strip any APM_REGISTRY_* env vars a prior test in + // this process may have left behind, so a passing result here can only be explained by that + // config file. + for _, envVar := range os.Environ() { + if strings.HasPrefix(envVar, "APM_REGISTRY_") { + _ = os.Unsetenv(strings.SplitN(envVar, "=", 2)[0]) + } + } + + owner, pkgName := "test", "native-cli-pkg" + + // Step 1: publish using the native apm binary directly (no "jf agent apm publish", no + // --build-name/--build-number). + publishDir, err := os.MkdirTemp("", "apm-native-publish-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(publishDir) + }() + require.NoError(t, os.MkdirAll(filepath.Join(publishDir, ".apm", "primitives"), dirPerms)) + apmYaml := fmt.Sprintf(`name: %s +version: 1.0.0 +license: UNLICENSED +targets: + - claude +primitives: + agents: [] +`, pkgName) + require.NoError(t, os.WriteFile(filepath.Join(publishDir, "apm.yml"), []byte(apmYaml), filePerms)) + require.NoError(t, os.WriteFile(filepath.Join(publishDir, ".apm", "primitives", "placeholder.txt"), []byte("placeholder content"), filePerms)) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + clientTestUtils.ChangeDirAndAssert(t, publishDir) + + nativePublish := exec.Command("apm", "publish", "--package", owner+"/"+pkgName, "--registry", tests.AgentPackagesLocalRepo) // #nosec G204 -- fixed argv, no shell, no user input + nativePublish.Stdout = os.Stdout + nativePublish.Stderr = os.Stderr + require.NoError(t, nativePublish.Run(), "native apm publish (no jf wrapper) should succeed using jf setup apm's persisted credentials") + + // Verify the package was genuinely uploaded to Artifactory - not just that apm exited 0. + searchSpec := spec.NewBuilder(). + Pattern(tests.AgentPackagesLocalRepo + "/" + owner + "/" + pkgName + "/*.zip"). + BuildSpec() + publishedArtifacts, _, err := tests.SearchFiles(searchSpec, serverDetails) + require.NoError(t, err) + require.NotEmpty(t, publishedArtifacts, "native apm publish should have uploaded the package to Artifactory") + assert.True(t, + strings.HasPrefix(publishedArtifacts[0].Name, pkgName+"-") && strings.HasSuffix(publishedArtifacts[0].Name, ".zip"), + "published artifact name should follow -.zip, got %q", publishedArtifacts[0].Name) + + // Step 2: install that same package using the native apm binary, from a separate consumer + // project (no "jf agent apm install"). + installDir, err := os.MkdirTemp("", "apm-native-install-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(installDir) + }() + consumerYaml := fmt.Sprintf(`version: "1.0.0" +name: native-cli-consumer +license: UNLICENSED +targets: + - claude +dependencies: + apm: + - %s/%s#1.0.0 +`, owner, pkgName) + require.NoError(t, os.MkdirAll(filepath.Join(installDir, ".apm"), dirPerms)) + require.NoError(t, os.WriteFile(filepath.Join(installDir, "apm.yml"), []byte(consumerYaml), filePerms)) + + clientTestUtils.ChangeDirAndAssert(t, installDir) + + nativeInstall := exec.Command("apm", "install") // #nosec G204 -- fixed argv, no shell, no user input + nativeInstall.Stdout = os.Stdout + nativeInstall.Stderr = os.Stderr + require.NoError(t, nativeInstall.Run(), "native apm install (no jf wrapper) should succeed using jf setup apm's persisted credentials") + + // Verify the package was genuinely resolved from Artifactory - not just that apm exited 0. + lockfilePath := filepath.Join(installDir, "apm.lock.yaml") + require.FileExists(t, lockfilePath, "apm.lock.yaml should exist after native apm install") + lockfileContent, err := os.ReadFile(lockfilePath) + require.NoError(t, err) + assert.Contains(t, string(lockfileContent), pkgName, "lockfile should reference the installed package") + assert.Contains(t, string(lockfileContent), "1.0.0", "lockfile should record the resolved version") + + // Clean up the published artifact from Artifactory. + _, _, _ = tests.DeleteFiles(searchSpec, serverDetails) +} + +// TestApmInstallPositionalPackageWithBuildInfo validates +// "jf agent apm install /#" - naming the dependency directly on the +// command line, which both adds it to apm.yml and installs it in one step. Every other +// install-with-dependency test in this file pre-declares the dependency in apm.yml's +// dependencies: block first and calls plain "install"; this is the one CLI-driven path. +func TestApmInstallPositionalPackageWithBuildInfo(t *testing.T) { + initApmTest(t) + defer cleanApmTest(t) + + publishApmDependencyPackage(t, "test/positional-install-dep", "1.0.0") + + projectDir, err := os.MkdirTemp("", "apm-positional-install-*") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(projectDir) + }() + + createApmTestProject(t, projectDir) + + buildNumber := "111" + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + clientTestUtils.ChangeDirAndAssert(t, projectDir) + + // Install by naming the package directly on the command line, not by pre-declaring it in + // apm.yml first. + err = getApmCli().Exec("agent", "apm", "install", "test/positional-install-dep#1.0.0", "--build-name", apmBuildName, "--build-number", buildNumber) + require.NoError(t, err, "jf agent apm install /# should succeed") + + // apm.yml should have been updated with the new dependency as a side effect. + apmYamlContent, err := os.ReadFile(filepath.Join(projectDir, "apm.yml")) + require.NoError(t, err) + assert.Contains(t, string(apmYamlContent), "test/positional-install-dep", "apm.yml should be updated with the positionally-installed dependency") + + // Verify the dependency is captured in build info, with a real checksum - not just that the + // install command itself succeeded. + buildResult := fetchPublishedApmBuildInfo(t, apmBuildName, buildNumber) + require.NotEmpty(t, buildResult.Modules, "build info should have a module") + module := buildResult.Modules[0] + require.NotEmpty(t, module.Dependencies, "build info should have a dependency") + + var found bool + for _, dep := range module.Dependencies { + if strings.Contains(dep.Id, "positional-install-dep") { + found = true + assert.NotEmpty(t, dep.Sha256, "positionally-installed dependency should have a SHA256 checksum") + } + } + assert.True(t, found, "build info dependency list should include the positionally-installed package") + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, apmBuildName, artHttpDetails) +} diff --git a/go.mod b/go.mod index 63b6d026b..67df0f5cf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ replace ( github.com/CycloneDX/cyclonedx-go => github.com/CycloneDX/cyclonedx-go v0.10.0 // Should not be updated to 0.2.6 due to a bug (https://github.com/jfrog/jfrog-cli-core/pull/372) github.com/c-bata/go-prompt => github.com/c-bata/go-prompt v0.2.5 + + github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816155757-2229d1c9671c // Should not be updated to 0.2.0-beta.2 due to a bug (https://github.com/jfrog/jfrog-cli-core/pull/372) github.com/pkg/term => github.com/pkg/term v1.1.0 ) @@ -21,8 +23,8 @@ require ( github.com/jfrog/build-info-go v1.13.1-0.20260811071930-3b99d4a6c84b github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847 - github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814071425-102723197072 + github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816155757-2229d1c9671c + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 github.com/jfrog/jfrog-cli-evidence v0.10.0 github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab github.com/jfrog/jfrog-cli-security v1.33.1 diff --git a/go.sum b/go.sum index 67cea7e01..751bca03f 100644 --- a/go.sum +++ b/go.sum @@ -402,10 +402,10 @@ github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYL github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847 h1:wahxu7URLrhdHtI3CVH3aE1Y3eeubDin13t+QVJBeW8= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9 h1:6N87yf7y1Zce1DvqZ1zX1sWirdJ7lh04mFvjr1JhVL0= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9/go.mod h1:eYyYY+GMdddy75/rCWiKg8EydGp/jRY+9+30QkGpbGw= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814071425-102723197072 h1:lWQd4C+sXVQW3xvFhQkkLivwx5//bQnjbd+P5Cr3FLY= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814071425-102723197072/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816155757-2229d1c9671c h1:e5zESEeG6dcpUWuqWSRYMdDzvAQrNapwjDczxGiWk0A= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816155757-2229d1c9671c/go.mod h1:F+LLZTRyXsBjEPk+TU4EsAsl3IM5z1o/676JR+csoVM= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 h1:+/wQ/UeJE+f16bVbGG3C5cgE8UwMuXZQxaJrq15lr24= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= github.com/jfrog/jfrog-cli-evidence v0.10.0 h1:9wbdHOl+wcN3crNw5qtQtQ0N28NX+9QH/Yo3Ia+iYhc= github.com/jfrog/jfrog-cli-evidence v0.10.0/go.mod h1:xTtHBeiVg3gbJ7jcx48sMlcWlCsRnvqlPKpbGJt22k0= github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab h1:Zn/qB8LYhSu82YDtbqXwErN1RPHTHe/a3gQY6Ti/OBE= diff --git a/main_test.go b/main_test.go index 7f42ecce6..6019eaad9 100644 --- a/main_test.go +++ b/main_test.go @@ -18,9 +18,9 @@ import ( commandUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/common/format" - corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" "github.com/jfrog/jfrog-cli-core/v2/common/project" "github.com/jfrog/jfrog-cli-core/v2/common/spec" + corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-core/v2/utils/log" @@ -77,7 +77,7 @@ func setupIntegrationTests() { InitArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { InitBuildToolsTests() } if *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan { @@ -125,7 +125,7 @@ func tearDownIntegrationTests() { if (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanBuildToolsTests() } if *tests.TestDistribution { diff --git a/testdata/agent_packages_local_repository_config.json b/testdata/agent_packages_local_repository_config.json new file mode 100644 index 000000000..84298ee41 --- /dev/null +++ b/testdata/agent_packages_local_repository_config.json @@ -0,0 +1,5 @@ +{ + "key": "${AGENT_PACKAGES_LOCAL_REPO}", + "rclass": "local", + "packageType": "agentpackages" +} diff --git a/utils/tests/consts.go b/utils/tests/consts.go index f07e654ec..f1b480ceb 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -123,6 +123,7 @@ const ( UvVirtualRepositoryConfig = "uv_virtual_repository_config.json" AgentPluginsLocalRepositoryConfig = "agent_plugins_local_repository_config.json" AgentSkillsLocalRepositoryConfig = "skills_local_repository_config.json" + AgentPackagesLocalRepositoryConfig = "agent_packages_local_repository_config.json" ConanLocalRepositoryConfig = "conan_local_repository_config.json" ConanRemoteRepositoryConfig = "conan_remote_repository_config.json" ConanVirtualRepositoryConfig = "conan_virtual_repository_config.json" @@ -240,6 +241,7 @@ var ( UvVirtualRepo = "cli-uv-virtual" AgentPluginsLocalRepo = "cli-agent-plugins-local" AgentSkillsLocalRepo = "cli-agent-skills-local" + AgentPackagesLocalRepo = "cli-agent-packages-local" ConanLocalRepo = "cli-conan-local" ConanRemoteRepo = "cli-conan-remote" ConanVirtualRepo = "cli-conan-virtual" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index 55355c981..a7e327816 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -75,6 +75,7 @@ var ( TestApt *bool TestAgentPlugins *bool TestAgentSkills *bool + TestApm *bool TestConan *bool TestHelm *bool TestHuggingFace *bool @@ -147,6 +148,7 @@ func init() { TestApt = flag.Bool("test.apt", false, "Test apt (Debian/Ubuntu package manager)") TestAgentPlugins = flag.Bool("test.agentPlugins", false, "Test Agent Plugins") TestAgentSkills = flag.Bool("test.agentSkills", false, "Test Agent Skills") + TestApm = flag.Bool("test.apm", false, "Test APM (Agent Package Manager)") TestConan = flag.Bool("test.conan", false, "Test Conan") TestHelm = flag.Bool("test.helm", false, "Test Helm") TestHuggingFace = flag.Bool("test.huggingface", false, "Test HuggingFace") @@ -281,8 +283,49 @@ func DeleteFiles(deleteSpec *spec.SpecFiles, serverDetails *config.ServerDetails return deleteCommand.DeleteFiles(reader) } +// SearchFiles searches for files in Artifactory using the provided spec and server details. +// Returns search results as utils.ResultItem (repo/path/name/properties/checksums) and a count. +// +// Deliberately decodes into utils.ResultItem, not artUtils.SearchResult: the latter has no Name +// field at all and a Props field shaped/tagged for a different JSON payload than what the AQL +// search reader actually emits, so it silently comes back with an empty filename and empty +// properties on every record - see ConvertArtifactsSearchDetailsToBuildInfoArtifacts in +// jfrog-cli-core for the same reader decoded into the same, correct type. +func SearchFiles(searchSpec *spec.SpecFiles, serverDetails *config.ServerDetails) (searchResults []utils.ResultItem, count int, err error) { + servicesManager, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + return nil, 0, err + } + + // Use the search utilities from jfrog-cli-core + readers, _, err := artUtils.SearchFiles(servicesManager, searchSpec) + if err != nil { + return nil, 0, err + } + defer func() { + for _, r := range readers { + ioutils.Close(r, &err) + } + }() + + // Process search results from readers + for _, reader := range readers { + for item := new(utils.ResultItem); reader.NextRecord(item) == nil; item = new(utils.ResultItem) { + searchResults = append(searchResults, *item) + } + } + + return searchResults, len(searchResults), nil +} + // This function makes no assertion, caller is responsible to assert as needed. func GetBuildInfo(serverDetails *config.ServerDetails, buildName, buildNumber string) (pbi *buildinfo.PublishedBuildInfo, found bool, err error) { + return GetBuildInfoInProject(serverDetails, buildName, buildNumber, "") +} + +// GetBuildInfoInProject is GetBuildInfo scoped to an Artifactory project key. +// This function makes no assertion, caller is responsible to assert as needed. +func GetBuildInfoInProject(serverDetails *config.ServerDetails, buildName, buildNumber, projectKey string) (pbi *buildinfo.PublishedBuildInfo, found bool, err error) { servicesManager, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { return nil, false, err @@ -290,6 +333,7 @@ func GetBuildInfo(serverDetails *config.ServerDetails, buildName, buildNumber st params := services.NewBuildInfoParams() params.BuildName = buildName params.BuildNumber = buildNumber + params.ProjectKey = projectKey return servicesManager.GetBuildInfo(params) } @@ -332,6 +376,7 @@ var reposConfigMap = map[*string]string{ &UvVirtualRepo: UvVirtualRepositoryConfig, &AgentPluginsLocalRepo: AgentPluginsLocalRepositoryConfig, &AgentSkillsLocalRepo: AgentSkillsLocalRepositoryConfig, + &AgentPackagesLocalRepo: AgentPackagesLocalRepositoryConfig, &NixLocalRepo: NixLocalRepositoryConfig, &NixRemoteRepo: NixRemoteRepositoryConfig, &NixVirtualRepo: NixVirtualRepositoryConfig, @@ -418,6 +463,7 @@ func GetNonVirtualRepositories() map[*string]string { TestApt: {&AptLocalRepo, &AptRemoteRepo, &AptDebianRemoteRepo}, TestAgentPlugins: {&AgentPluginsLocalRepo}, TestAgentSkills: {&AgentSkillsLocalRepo}, + TestApm: {&AgentPackagesLocalRepo}, TestConan: {&ConanLocalRepo, &ConanRemoteRepo}, TestHelm: {&HelmLocalRepo}, TestHuggingFace: {&HuggingFaceLocalRepo}, @@ -565,6 +611,7 @@ func getSubstitutionMap() map[string]string { "${UV_VIRTUAL_REPO}": UvVirtualRepo, "${AGENT_PLUGINS_LOCAL_REPO}": AgentPluginsLocalRepo, "${AGENT_SKILLS_LOCAL_REPO}": AgentSkillsLocalRepo, + "${AGENT_PACKAGES_LOCAL_REPO}": AgentPackagesLocalRepo, "${NIX_LOCAL_REPO}": NixLocalRepo, "${NIX_REMOTE_REPO}": NixRemoteRepo, "${NIX_VIRTUAL_REPO}": NixVirtualRepo,