diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.test.ts index b6f95df62cf04..20d50784e93c8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.test.ts @@ -198,14 +198,14 @@ describe('When using EPM `get` services', () => { }); }); - it('should query and paginate SO using package name as filter', async () => { + it('should query and paginate SO using package name and NOT latest_revision:false filter', async () => { await getPackageUsageStats({ savedObjectsClient: soClient, pkgName: 'system' }); expect(soClient.find).toHaveBeenNthCalledWith( 1, expect.objectContaining({ type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, perPage: 10000, - filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: system`, + filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: system AND NOT ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision: false`, }) ); }); @@ -218,6 +218,71 @@ describe('When using EPM `get` services', () => { package_policy_count: 4, }); }); + + it('should exclude :prev (latest_revision:false) policies from the count', async () => { + const soClientPrev = savedObjectsClientMock.create(); + // Mix of current policies and a :prev policy that would be returned before + // the filter fix — the filter now excludes it, so mock returns only current ones. + soClientPrev.find.mockResolvedValue({ + page: 1, + per_page: 10000, + total: 2, + saved_objects: [ + { + type: 'ingest-package-policies', + id: 'policy-1', + attributes: { + name: 'system-1', + namespace: 'default', + package: { name: 'system', title: 'System', version: '1.0.0' }, + enabled: true, + policy_id: 'ap-1', + policy_ids: ['ap-1'], + inputs: [], + revision: 2, + created_at: '2020-01-01T00:00:00.000Z', + created_by: 'elastic', + updated_at: '2020-01-01T00:00:00.000Z', + updated_by: 'elastic', + }, + references: [], + score: 0, + }, + { + type: 'ingest-package-policies', + id: 'policy-2', + attributes: { + name: 'system-2', + namespace: 'default', + package: { name: 'system', title: 'System', version: '1.0.0' }, + enabled: true, + policy_id: 'ap-2', + policy_ids: ['ap-2'], + inputs: [], + revision: 2, + created_at: '2020-01-01T00:00:00.000Z', + created_by: 'elastic', + updated_at: '2020-01-01T00:00:00.000Z', + updated_by: 'elastic', + }, + references: [], + score: 0, + }, + ], + }); + + const result = await getPackageUsageStats({ + savedObjectsClient: soClientPrev, + pkgName: 'system', + }); + + // 2 current policies, not 3 (the :prev one is excluded by the filter) + expect(result.package_policy_count).toBe(2); + // Verify the filter contains the NOT latest_revision:false clause + const [[callArgs]] = soClientPrev.find.mock.calls; + expect(callArgs.filter).toContain('NOT'); + expect(callArgs.filter).toContain('latest_revision'); + }); }); describe('getPackages', () => { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.ts index 0e611fa9caadc..fd985a3130ec9 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get.ts @@ -653,7 +653,7 @@ export const getPackageUsageStats = async ({ const filter = normalizeKuery( packagePolicySavedObjectType, - `${packagePolicySavedObjectType}.package.name: ${pkgName}` + `${packagePolicySavedObjectType}.package.name: ${pkgName} AND NOT ${packagePolicySavedObjectType}.latest_revision: false` ); const agentPolicyCount = new Set(); // using saved Objects client directly, instead of the `list()` method of `package_policy` service diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.test.ts new file mode 100644 index 0000000000000..0374c8b5faca3 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; + +import { getPackagePoliciesCountByPackageName } from './package_policies_aggregation'; + +// The mock resolves to the legacy (non-space-aware) type, matching a self-managed deployment. +const MOCKED_SO_TYPE = 'ingest-package-policies'; + +jest.mock('../package_policy', () => ({ + getPackagePolicySavedObjectType: jest.fn().mockResolvedValue('ingest-package-policies'), +})); + +describe('getPackagePoliciesCountByPackageName', () => { + it('uses NOT latest_revision:false filter so policies without the field are included', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + aggregations: { + count_by_package_name: { buckets: [] }, + }, + }); + + await getPackagePoliciesCountByPackageName(soClient); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: `NOT ${MOCKED_SO_TYPE}.attributes.latest_revision:false`, + }) + ); + }); + + it('includes a size parameter in the terms aggregation to avoid ES default truncation at 10 buckets', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + aggregations: { + count_by_package_name: { buckets: [] }, + }, + }); + + await getPackagePoliciesCountByPackageName(soClient); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + aggs: expect.objectContaining({ + count_by_package_name: expect.objectContaining({ + terms: expect.objectContaining({ + size: expect.any(Number), + }), + }), + }), + }) + ); + const [[callArgs]] = soClient.find.mock.calls; + const termsSize = (callArgs.aggs as any)?.count_by_package_name?.terms?.size; + expect(termsSize).toBeGreaterThan(10); + }); + + it('returns a map of package name to count', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 0, + total: 3, + saved_objects: [], + aggregations: { + count_by_package_name: { + buckets: [ + { key: 'nginx', doc_count: 2 }, + { key: 'system', doc_count: 1 }, + ], + }, + }, + }); + + const result = await getPackagePoliciesCountByPackageName(soClient); + + expect(result).toEqual({ nginx: 2, system: 1 }); + }); + + it('returns an empty object when there are no buckets', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + aggregations: { + count_by_package_name: { buckets: [] }, + }, + }); + + const result = await getPackagePoliciesCountByPackageName(soClient); + + expect(result).toEqual({}); + }); + + it('returns an empty object when aggregations are absent', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + }); + + const result = await getPackagePoliciesCountByPackageName(soClient); + + expect(result).toEqual({}); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.ts index 7f78af9393118..de7c4f810d735 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/package_policies_aggregation.ts @@ -7,6 +7,8 @@ import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { SO_SEARCH_LIMIT } from '../../../common'; + import { getPackagePolicySavedObjectType } from '../package_policy'; export async function getPackagePoliciesCountByPackageName(soClient: SavedObjectsClientContract) { @@ -18,11 +20,17 @@ export async function getPackagePoliciesCountByPackageName(soClient: SavedObject >({ type: savedObjectType, perPage: 0, - filter: `${savedObjectType}.attributes.latest_revision:true`, + // Use NOT false instead of :true so that policies without the field + // (8.x policies where latest_revision was never persisted to ES) are + // treated as current revisions and included in the count. + filter: `NOT ${savedObjectType}.attributes.latest_revision:false`, aggs: { count_by_package_name: { terms: { field: `${savedObjectType}.attributes.package.name`, + // Without an explicit size, ES defaults to 10 buckets and silently + // truncates results when more than 10 distinct package names exist. + size: SO_SEARCH_LIMIT, }, }, }, diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts index 355cfa72d0f97..10a1ad8f46262 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts @@ -1994,6 +1994,42 @@ describe('Package policy service', () => { }); describe('list', () => { + it('should use NOT latest_revision:false filter to include 8.x policies without the field', async () => { + const soClient = createSavedObjectClientMock(); + soClient.find.mockResolvedValueOnce({ + total: 0, + page: 1, + per_page: 20, + saved_objects: [], + }); + + await packagePolicyService.list(soClient, {}); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false`, + }) + ); + }); + + it('should combine user kuery with NOT latest_revision:false filter', async () => { + const soClient = createSavedObjectClientMock(); + soClient.find.mockResolvedValueOnce({ + total: 0, + page: 1, + per_page: 20, + saved_objects: [], + }); + + await packagePolicyService.list(soClient, { kuery: 'ingest-package-policies.name: nginx' }); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false AND (ingest-package-policies.attributes.name: nginx)`, + }) + ); + }); + it('should call audit logger', async () => { const soClient = createSavedObjectClientMock(); soClient.find.mockResolvedValueOnce({ @@ -10702,7 +10738,7 @@ describe('Package policy service', () => { sortField: 'created_at', sortOrder: 'asc', fields: [], - filter: `${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:true`, + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false`, }) ); }); @@ -10722,7 +10758,7 @@ describe('Package policy service', () => { sortField: 'created_at', sortOrder: 'asc', fields: [], - filter: `${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:true AND (one=two)`, + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false AND (one=two)`, }) ); }); @@ -10776,7 +10812,7 @@ describe('Package policy service', () => { sortField: 'created_at', sortOrder: 'asc', fields: [], - filter: `${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:true`, + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false`, }) ); }); @@ -10794,7 +10830,7 @@ describe('Package policy service', () => { sortField: 'created_at', sortOrder: 'asc', fields: [], - filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:true`, + filter: `NOT ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false`, }) ); }); @@ -10815,7 +10851,7 @@ describe('Package policy service', () => { perPage: 12, sortField: 'updated_by', sortOrder: 'desc', - filter: `${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:true AND (one=two)`, + filter: `NOT ${LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.latest_revision:false AND (one=two)`, }) ); }); @@ -12177,6 +12213,29 @@ describe('getCompiledVersionsForAgentPolicy()', () => { expect(soClient.find).not.toHaveBeenCalled(); }); + it('uses NOT latest_revision:false filter so 8.x policies are included', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10000, + page: 1, + }); + + await getCompiledVersionsForAgentPolicy(soClient, 'agent-policy-1'); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: expect.stringContaining('NOT'), + }) + ); + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: expect.not.stringContaining('latest_revision:true'), + }) + ); + }); + it('returns empty array when no package policies have inputs_for_versions', async () => { const soClient = savedObjectsClientMock.create(); soClient.find.mockResolvedValue({ diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts index d747cd75b6c1d..56af343afa573 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts @@ -301,9 +301,10 @@ export async function getCompiledVersionsForAgentPolicy( const savedObjectType = await getPackagePolicySavedObjectType(); const packagePolicySOs = await soClient.find({ type: savedObjectType, - filter: `${savedObjectType}.attributes.policy_ids:${escapeSearchQueryPhrase( - agentPolicyId - )} AND ${savedObjectType}.attributes.latest_revision:true`, + filter: buildCurrentRevisionFilter( + savedObjectType, + `${savedObjectType}.attributes.policy_ids:${escapeSearchQueryPhrase(agentPolicyId)}` + ), perPage: SO_SEARCH_LIMIT, }); @@ -316,6 +317,15 @@ export async function getCompiledVersionsForAgentPolicy( return [...versionKeys]; } +/** + * Returns a kuery string that excludes package policies with latest_revision:false, + * optionally AND-ing with an additional kuery clause. + */ +export function buildCurrentRevisionFilter(savedObjectType: string, kuery?: string): string { + const base = `NOT ${savedObjectType}.attributes.latest_revision:false`; + return kuery ? `${base} AND (${kuery})` : base; +} + export function _normalizePackagePolicyKuery(savedObjectType: string, kuery: string) { if (savedObjectType === LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE) { return normalizeKuery( @@ -1245,9 +1255,10 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const packagePolicySO = await soClient .find({ type: savedObjectType, - filter: `${savedObjectType}.attributes.policy_ids:${escapeSearchQueryPhrase( - agentPolicyId - )} AND ${savedObjectType}.attributes.latest_revision:true`, + filter: buildCurrentRevisionFilter( + savedObjectType, + `${savedObjectType}.attributes.policy_ids:${escapeSearchQueryPhrase(agentPolicyId)}` + ), perPage: SO_SEARCH_LIMIT, namespaces: isSpacesEnabled ? options.spaceIds : undefined, }) @@ -1369,9 +1380,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const filter = _normalizePackagePolicyKuery( savedObjectType, - kuery - ? `${savedObjectType}.attributes.latest_revision:true AND (${kuery})` - : `${savedObjectType}.attributes.latest_revision:true` + buildCurrentRevisionFilter(savedObjectType, kuery) ); const packagePolicies = await soClient @@ -1391,7 +1400,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { auditLoggingService.writeCustomSoAuditLog({ action: 'find', id: packagePolicy.id, - name: packagePolicy.attributes.name, + name: packagePolicy.attributes?.name, savedObjectType, }); } @@ -1430,9 +1439,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const filter = _normalizePackagePolicyKuery( savedObjectType, - kuery - ? `${savedObjectType}.attributes.latest_revision:true AND (${kuery})` - : `${savedObjectType}.attributes.latest_revision:true` + buildCurrentRevisionFilter(savedObjectType, kuery) ); const packagePolicies = await soClient @@ -1452,7 +1459,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { auditLoggingService.writeCustomSoAuditLog({ action: 'find', id: packagePolicy.id, - name: packagePolicy.attributes.name, + name: packagePolicy.attributes?.name, savedObjectType, }); } @@ -3035,9 +3042,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const filter = _normalizePackagePolicyKuery( savedObjectType, - kuery - ? `${savedObjectType}.attributes.latest_revision:true AND (${kuery})` - : `${savedObjectType}.attributes.latest_revision:true` + buildCurrentRevisionFilter(savedObjectType, kuery) ); return createSoFindIterable<{}>({ @@ -3083,9 +3088,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const filter = _normalizePackagePolicyKuery( savedObjectType, - kuery - ? `${savedObjectType}.attributes.latest_revision:true AND (${kuery})` - : `${savedObjectType}.attributes.latest_revision:true` + buildCurrentRevisionFilter(savedObjectType, kuery) ); return createSoFindIterable({ diff --git a/x-pack/platform/test/fleet_api_integration/apis/agents/privileges.ts b/x-pack/platform/test/fleet_api_integration/apis/agents/privileges.ts index 8748434fbe000..ff178dbadda90 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/agents/privileges.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/agents/privileges.ts @@ -60,7 +60,7 @@ export default function (providerContext: FtrProviderContext) { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, overwrite: true, attributes: { - policy_id: 'fleet-server-policy', + policy_ids: ['fleet-server-policy'], name: 'Fleet Server', package: { name: 'fleet_server', diff --git a/x-pack/platform/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/platform/test/fleet_api_integration/apis/agents/upgrade.ts index d9b068ad0f2ca..8624931317e71 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/agents/upgrade.ts @@ -75,7 +75,7 @@ export default function (providerContext: FtrProviderContext) { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, overwrite: true, attributes: { - policy_id: 'fleet-server-policy', + policy_ids: ['fleet-server-policy'], name: 'Fleet Server', package: { name: 'fleet_server', @@ -654,7 +654,7 @@ export default function (providerContext: FtrProviderContext) { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, overwrite: true, attributes: { - policy_id: 'fleet-server-policy', + policy_ids: ['fleet-server-policy'], name: 'Fleet Server', package: { name: 'fleet_server', diff --git a/x-pack/platform/test/fleet_api_integration/apis/epm/list.ts b/x-pack/platform/test/fleet_api_integration/apis/epm/list.ts index 7fd7236446a05..fb61b5d368aaa 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/epm/list.ts @@ -6,6 +6,11 @@ */ import expect from '@kbn/expect'; +import type { Client } from '@elastic/elasticsearch'; +import { + INGEST_SAVED_OBJECT_INDEX, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, +} from '@kbn/fleet-plugin/common/constants'; import type { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; import { testUsers } from '../test_users'; @@ -18,6 +23,7 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const fleetAndAgents = getService('fleetAndAgents'); const kibanaServer = getService('kibanaServer'); + const es: Client = getService('es'); const apiClient = new SpaceTestApiClient(supertest); // use function () {} and not () => {} here @@ -95,6 +101,125 @@ export default function (providerContext: FtrProviderContext) { } }); + it('counts package policies whose latest_revision field is absent (simulates 8.x policies after upgrade)', async function () { + // Install nginx and create a policy — the normal path sets latest_revision:true. + await apiClient.installPackage({ pkgName: 'nginx', force: true, pkgVersion: '1.20.0' }); + const policyRes = await apiClient.createPackagePolicy(undefined, { + policy_ids: [], + name: `test-nginx-legacy-${Date.now()}`, + description: 'test', + package: { name: 'nginx', version: '1.20.0' }, + inputs: {}, + }); + const policyId = policyRes.item.id; + + // Simulate an 8.x policy by removing the latest_revision field from the SO document. + await es.updateByQuery({ + index: INGEST_SAVED_OBJECT_INDEX, + refresh: true, + script: { + lang: 'painless', + source: `ctx._source['${PACKAGE_POLICY_SAVED_OBJECT_TYPE}'].remove('latest_revision')`, + }, + query: { + bool: { + must: [ + { term: { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE } }, + { term: { _id: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}:${policyId}` } }, + ], + }, + }, + }); + + try { + const listResponse = await supertest + .get('/api/fleet/epm/packages?withPackagePoliciesCount=true') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const nginxItem = listResponse.body.items.find((item: any) => item.name === 'nginx'); + expect(nginxItem).to.be.ok(); + // The policy with absent latest_revision must be counted (NOT false filter). + expect(nginxItem.packagePoliciesInfo.count).to.be.greaterThan(0); + } finally { + // Restore latest_revision:true so this document does not affect other tests. + await es.updateByQuery({ + index: INGEST_SAVED_OBJECT_INDEX, + refresh: true, + script: { + lang: 'painless', + source: `ctx._source['${PACKAGE_POLICY_SAVED_OBJECT_TYPE}']['latest_revision'] = true`, + }, + query: { + bool: { + must: [ + { term: { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE } }, + { term: { _id: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}:${policyId}` } }, + ], + }, + }, + }); + await apiClient.deletePackagePolicy(policyId).catch(() => {}); + } + }); + + it('does not count :prev (latest_revision:false) policies in the package policies count', async function () { + // Install nginx (may already be installed from previous test — force is safe). + await apiClient.installPackage({ pkgName: 'nginx', force: true, pkgVersion: '1.20.0' }); + + // Inject a :prev rollback document directly into the SO index to simulate the state + // created by the package rollback feature. + const prevDocId = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}:fake-rollback-id:prev`; + await es.index({ + index: INGEST_SAVED_OBJECT_INDEX, + id: prevDocId, + refresh: true, + document: { + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + namespaces: ['default'], + [PACKAGE_POLICY_SAVED_OBJECT_TYPE]: { + name: 'nginx-prev', + namespace: 'default', + package: { name: 'nginx', title: 'Nginx', version: '1.19.0' }, + enabled: true, + policy_id: 'nonexistent-policy', + policy_ids: ['nonexistent-policy'], + inputs: [], + revision: 1, + latest_revision: false, + created_at: new Date().toISOString(), + created_by: 'elastic', + updated_at: new Date().toISOString(), + updated_by: 'elastic', + }, + }, + }); + + const listResponse = await supertest + .get('/api/fleet/epm/packages?withPackagePoliciesCount=true') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const nginxItem = listResponse.body.items.find((item: any) => item.name === 'nginx'); + expect(nginxItem).to.be.ok(); + // The :prev document must not be counted — it has latest_revision:false. + // Count should only reflect policies where latest_revision is true or absent. + const statsRes = await supertest + .get('/api/fleet/epm/packages/nginx/stats') + .set('kbn-xsrf', 'xxx') + .expect(200); + + // The stats endpoint (Bug 3 fix) must also exclude the :prev document. + expect(statsRes.body.response.package_policy_count).to.equal( + nginxItem.packagePoliciesInfo.count + ); + + // Clean up the injected document. + await es + .delete({ index: INGEST_SAVED_OBJECT_INDEX, id: prevDocId, refresh: true }) + .catch(() => {}); + }); + it('allows user with only fleet permission to access', async () => { await supertestWithoutAuth .get('/api/fleet/epm/packages')