Skip to content

Commit 3f3183f

Browse files
committed
feat: enhance field usage analysis and permission export functionality
- Added `truncated` flag to `FieldUsageStat` and `ScanFieldUsageResult` to indicate incomplete field data. - Updated `runFieldUsageQueryForObjects` to handle per-field truncation and reflect it in the UI. - Introduced tests for permission export findings to suppress warnings when permissions are truncated. - Enhanced `runPermissionExport` to dynamically select available system permission fields based on the org's capabilities. - Modified SOQL query generation to prevent invalid field errors by only including existing permission fields. - Improved field usage analysis view to display truncated status and provide user feedback on scanned percentages. - Refactored destructive delete eligibility checks to consider field scan truncation. - Added unit tests for `queryWithRecordBudget` to ensure correct handling of record limits and pagination.
1 parent 85e7b61 commit 3f3183f

15 files changed

Lines changed: 450 additions & 37 deletions

apps/api/src/app/db/user.db.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const UserFacingProfileSelect = {
8787
},
8888
entitlements: {
8989
select: {
90+
analysisTools: true,
9091
chromeExtension: true,
9192
desktop: true,
9293
googleDrive: true,
@@ -113,6 +114,7 @@ const UserFacingProfileSelect = {
113114
billingStatus: true,
114115
entitlements: {
115116
select: {
117+
analysisTools: true,
116118
chromeExtension: true,
117119
desktop: true,
118120
googleDrive: true,
@@ -149,6 +151,7 @@ export const findByIdWithSubscriptions = (id: string) => {
149151
name: true,
150152
entitlements: {
151153
select: {
154+
analysisTools: true,
152155
chromeExtension: true,
153156
desktop: true,
154157
googleDrive: true,

libs/features/analysis-shared/src/field-usage/__tests__/run-field-usage.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,67 @@ describe('runFieldUsageQueryForObjects (streaming)', () => {
292292
expect(queryMock.mock.calls[0][1]).toContain('COUNT(');
293293
});
294294

295+
it('marks only row-scanned fields truncated; COUNT-based fields stay exact', async () => {
296+
describeMock.mockResolvedValue({
297+
data: {
298+
label: 'Account',
299+
queryable: true,
300+
custom: false,
301+
fields: [
302+
{ name: 'Id', label: 'Id', type: 'id', calculated: false, custom: false, autoNumber: false, length: 18, scale: 0 },
303+
{ name: 'LastModifiedDate', label: 'LMD', type: 'datetime', calculated: false, custom: false, autoNumber: false, scale: 0 },
304+
{
305+
name: 'Custom_Text__c',
306+
label: 'Text',
307+
type: 'string',
308+
calculated: false,
309+
custom: true,
310+
autoNumber: false,
311+
length: 255,
312+
scale: 0,
313+
aggregatable: true,
314+
},
315+
{
316+
name: 'Active__c',
317+
label: 'Active',
318+
type: 'boolean',
319+
calculated: false,
320+
custom: true,
321+
autoNumber: false,
322+
scale: 0,
323+
aggregatable: true, // booleans never COUNT — still row-scanned
324+
},
325+
],
326+
},
327+
});
328+
329+
// One page larger than the 100K row budget forces the boolean's row scan to truncate.
330+
const overBudgetPage = Array.from({ length: 100_001 }, (_, index) => ({
331+
Id: `001${String(index).padStart(15, '0')}`,
332+
LastModifiedDate: '2026-01-01T00:00:00.000+0000',
333+
Active__c: index % 2 === 0,
334+
}));
335+
queryMock.mockImplementation(async (_org: unknown, soql: string) => {
336+
if (typeof soql === 'string' && soql.includes('COUNT(')) {
337+
return { queryResults: { done: true, totalSize: 1, records: [{ total: 250_000, c0: 90_000 }] } };
338+
}
339+
return { queryResults: { done: true, records: overBudgetPage } };
340+
});
341+
342+
const result = await runFieldUsageQueryForObjects(fakeOrg, ['Account']);
343+
344+
const account = result.objects.Account;
345+
expect(account.queryTruncated).toBe(true);
346+
expect(result.anyQueryTruncated).toBe(true);
347+
// The scanned boolean hit the row budget — flagged per-field.
348+
expect(account.fieldUsage.Active__c.truncated).toBe(true);
349+
expect(account.fieldUsage.Active__c.scanned).toBe(100_000);
350+
// The COUNT-based field is exact across all 250K rows despite the sibling scan truncating.
351+
expect(account.fieldUsage.Custom_Text__c.truncated).toBe(false);
352+
expect(account.fieldUsage.Custom_Text__c.filled).toBe(90_000);
353+
expect(account.fieldUsage.Custom_Text__c.scanned).toBe(250_000);
354+
});
355+
295356
it('throws when the cancellation signal trips between objects', async () => {
296357
describeMock.mockResolvedValue(buildDescribe());
297358
queryMock.mockResolvedValue(buildPage([], null));

libs/features/analysis-shared/src/field-usage/run-field-usage.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface FieldUsageStat {
2626
* (avoids `filled > totalRecords` when row counts drift between sequential chunk queries).
2727
*/
2828
scanned: number;
29+
/**
30+
* Whether THIS field's numbers may be incomplete. COUNT-aggregated fields are always exact
31+
* (`false`); row-scanned fields inherit their chunk's row-budget truncation. Lets the UI avoid
32+
* blocking delete-eligibility (or warning) on exact fields just because an unrelated field's
33+
* scan on the same object was truncated.
34+
*/
35+
truncated: boolean;
2936
}
3037

3138
export interface FieldUsageObjectPayload {
@@ -300,6 +307,8 @@ interface ScanFieldUsageResult {
300307
scannedByField: Record<string, number>;
301308
maxLmd: Record<string, string | null>;
302309
truncated: boolean;
310+
/** Per-field truncation — every field in a chunk shares that chunk's row-budget outcome. */
311+
truncatedByField: Record<string, boolean>;
303312
/** Rows scanned by the first chunk — the object-level "records scanned" when no COUNT total is available. */
304313
scanTotal: number;
305314
}
@@ -319,6 +328,7 @@ async function scanFieldUsage(
319328
const filled: Record<string, number> = Object.fromEntries(fieldNames.map((name) => [name, 0]));
320329
const maxLmd: Record<string, string | null> = Object.fromEntries(fieldNames.map((name) => [name, null]));
321330
const scannedByField: Record<string, number> = Object.fromEntries(fieldNames.map((name) => [name, 0]));
331+
const truncatedByField: Record<string, boolean> = Object.fromEntries(fieldNames.map((name) => [name, false]));
322332
let truncated = false;
323333
let scanTotal = 0;
324334
const chunks = buildFieldChunks(fieldNames, objectApiName);
@@ -348,6 +358,7 @@ async function scanFieldUsage(
348358

349359
for (const fieldName of chunkNames) {
350360
scannedByField[fieldName] = chunkRecordCount;
361+
truncatedByField[fieldName] = result.truncated;
351362
}
352363
if (result.truncated) {
353364
truncated = true;
@@ -357,7 +368,7 @@ async function scanFieldUsage(
357368
}
358369
}
359370

360-
return { filled, scannedByField, maxLmd, truncated, scanTotal };
371+
return { filled, scannedByField, maxLmd, truncated, truncatedByField, scanTotal };
361372
}
362373

363374
/**
@@ -424,6 +435,7 @@ export async function runFieldUsageQueryForObjects(
424435
const filled: Record<string, number> = {};
425436
const maxLmdWhenFilled: Record<string, string | null> = {};
426437
const scannedByField: Record<string, number> = {};
438+
const truncatedByField: Record<string, boolean> = {};
427439
let queryTruncated = false;
428440
let countTotal: number | null = null;
429441

@@ -447,6 +459,7 @@ export async function runFieldUsageQueryForObjects(
447459
// there is no truncation. COUNT cannot report a per-field "latest modified", so it stays null.
448460
scannedByField[name] = countResult.total;
449461
maxLmdWhenFilled[name] = null;
462+
truncatedByField[name] = false;
450463
}
451464
}
452465

@@ -459,6 +472,7 @@ export async function runFieldUsageQueryForObjects(
459472
filled[name] = scanResult.filled[name] ?? 0;
460473
scannedByField[name] = scanResult.scannedByField[name] ?? 0;
461474
maxLmdWhenFilled[name] = scanResult.maxLmd[name] ?? null;
475+
truncatedByField[name] = scanResult.truncatedByField[name] ?? false;
462476
}
463477
if (scanResult.truncated) {
464478
queryTruncated = true;
@@ -480,6 +494,7 @@ export async function runFieldUsageQueryForObjects(
480494
// Clamp defensively so display never shows >100% / <0% from any count drift.
481495
pct: Math.max(0, Math.min(100, rawPct)),
482496
latestFilledRowModified: maxLmdWhenFilled[name] ?? null,
497+
truncated: truncatedByField[name] ?? false,
483498
};
484499
}
485500

libs/features/analysis-shared/src/permission-export/__tests__/build-permission-export-findings.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,52 @@ describe('buildPermissionExportFindings — new findings', () => {
291291
});
292292
});
293293

294+
describe('buildPermissionExportFindings — truncation and scope suppression', () => {
295+
const parentId = '0PS1';
296+
297+
it('does not emit OLS_*_NO_FLS_ROWS when field permissions were truncated', () => {
298+
const objectPermissions = [{ ParentId: parentId, SobjectType: 'Account', PermissionsRead: true, PermissionsEdit: true }];
299+
const codes = buildPermissionExportFindings(objectPermissions, [], { truncatedCategories: ['fieldPermissions'] }).map((f) => f.code);
300+
expect(codes).not.toContain('OLS_READ_NO_FLS_ROWS');
301+
expect(codes).not.toContain('OLS_EDIT_NO_FLS_ROWS');
302+
});
303+
304+
it('does not emit FLS_WITHOUT_OLS_ROW when object permissions were truncated', () => {
305+
const fieldPermissions = [{ ParentId: parentId, SobjectType: 'Case', Field: 'Case.Subject', PermissionsRead: true }];
306+
const codes = buildPermissionExportFindings([], fieldPermissions, { truncatedCategories: ['objectPermissions'] }).map((f) => f.code);
307+
expect(codes).not.toContain('FLS_WITHOUT_OLS_ROW');
308+
});
309+
310+
it('does not emit TAB_VISIBLE_NO_OBJECT_READ when object permissions were truncated', () => {
311+
const context = {
312+
permissionSetTabSettings: [{ ParentId: parentId, Name: 'standard-Account', Visibility: 'DefaultOn' }],
313+
truncatedCategories: ['objectPermissions'],
314+
};
315+
const codes = buildPermissionExportFindings([], [], context).map((f) => f.code);
316+
expect(codes).not.toContain('TAB_VISIBLE_NO_OBJECT_READ');
317+
});
318+
319+
it('only evaluates tab findings for in-scope objects when the export was object-scoped', () => {
320+
const context = {
321+
permissionSetTabSettings: [
322+
{ ParentId: parentId, Name: 'standard-Account', Visibility: 'DefaultOn' },
323+
{ ParentId: parentId, Name: 'standard-Contact', Visibility: 'DefaultOn' },
324+
],
325+
objectScope: ['Account'],
326+
};
327+
const findings = buildPermissionExportFindings([], [], context).filter((f) => f.code === 'TAB_VISIBLE_NO_OBJECT_READ');
328+
// Contact tab is skipped — its ObjectPermissions rows were never fetched, so "no access" cannot be proven.
329+
expect(findings).toHaveLength(1);
330+
expect(findings[0].objectApiName).toBe('Account');
331+
});
332+
333+
it('does not emit OLS_READ_NO_FLS_ROWS when View All Fields is granted (intentional no-FLS setup)', () => {
334+
const objectPermissions = [{ ParentId: parentId, SobjectType: 'Account', PermissionsRead: true, PermissionsViewAllFields: true }];
335+
const codes = buildPermissionExportFindings(objectPermissions, []).map((f) => f.code);
336+
expect(codes).not.toContain('OLS_READ_NO_FLS_ROWS');
337+
});
338+
});
339+
294340
describe('buildIssueCodeSummary', () => {
295341
it('aggregates counts by code', () => {
296342
const summary = buildIssueCodeSummary([

libs/features/analysis-shared/src/permission-export/__tests__/run-permission-export.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { HIGH_RISK_SYSTEM_PERMISSIONS } from '@jetstream/shared/constants';
12
import type { SalesforceOrgUi } from '@jetstream/types';
23
import { beforeEach, describe, expect, it, vi } from 'vitest';
34
import { runPermissionExport } from '../run-permission-export';
45

5-
const { mockedQuery, mockedQueryMore } = vi.hoisted(() => ({
6+
const { mockedQuery, mockedQueryMore, mockedDescribeSObject } = vi.hoisted(() => ({
67
mockedQuery: vi.fn(),
78
mockedQueryMore: vi.fn(),
9+
mockedDescribeSObject: vi.fn(),
810
}));
911

1012
vi.mock('@jetstream/shared/data', async (importOriginal) => {
@@ -44,10 +46,21 @@ vi.mock('@jetstream/shared/data', async (importOriginal) => {
4446
...actual,
4547
query: mockedQuery,
4648
queryMore: mockedQueryMore,
49+
describeSObject: mockedDescribeSObject,
4750
queryWithRecordBudget,
4851
};
4952
});
5053

54+
/** Builds a PermissionSet describe response containing the provided `Permissions*` columns. */
55+
function permissionSetDescribe(permissionFieldNames: string[]) {
56+
return {
57+
data: {
58+
name: 'PermissionSet',
59+
fields: ['Id', 'Name', 'Label', ...permissionFieldNames].map((name) => ({ name })),
60+
},
61+
} as any;
62+
}
63+
5164
function done<T>(records: T[]) {
5265
return {
5366
queryResults: {
@@ -68,6 +81,8 @@ describe('runPermissionExport', () => {
6881
beforeEach(() => {
6982
mockedQuery.mockReset();
7083
mockedQueryMore.mockReset();
84+
mockedDescribeSObject.mockReset();
85+
mockedDescribeSObject.mockResolvedValue(permissionSetDescribe(HIGH_RISK_SYSTEM_PERMISSIONS.map(({ field }) => field)));
7186
});
7287

7388
it('returns an empty merged result when no valid parent ids are provided', async () => {
@@ -180,4 +195,52 @@ describe('runPermissionExport', () => {
180195
await expect(runPermissionExport(ORG, [PROFILE_PERM_SET_ID], [], { isCanceled: () => true })).rejects.toThrow('Job canceled');
181196
expect(mockedQuery).not.toHaveBeenCalled();
182197
});
198+
199+
it('omits system permission columns the org does not have from the PermissionSet SOQL', async () => {
200+
mockedDescribeSObject.mockResolvedValue(permissionSetDescribe(['PermissionsModifyAllData', 'PermissionsViewAllData']));
201+
mockedQuery.mockResolvedValue(done([]));
202+
203+
await runPermissionExport(ORG, [], [PERM_SET_ID]);
204+
205+
const permissionSetSoql = mockedQuery.mock.calls[0][1] as string;
206+
expect(permissionSetSoql).toContain('PermissionsModifyAllData');
207+
expect(permissionSetSoql).toContain('PermissionsViewAllData');
208+
expect(permissionSetSoql).not.toContain('PermissionsAuthorApex');
209+
expect(permissionSetSoql).not.toContain('PermissionsManageUsers');
210+
});
211+
212+
it('selects the full system permission catalog when the PermissionSet describe fails', async () => {
213+
mockedDescribeSObject.mockRejectedValue(new Error('describe unavailable'));
214+
mockedQuery.mockResolvedValue(done([]));
215+
216+
await runPermissionExport(ORG, [], [PERM_SET_ID]);
217+
218+
const permissionSetSoql = mockedQuery.mock.calls[0][1] as string;
219+
for (const { field } of HIGH_RISK_SYSTEM_PERMISSIONS) {
220+
expect(permissionSetSoql).toContain(field);
221+
}
222+
});
223+
224+
it('never emits a lower percent after totalSteps grows for group chunks', async () => {
225+
const permissionSetRows = [{ Id: PERM_SET_ID, Name: 'CustomPermSet', IsOwnedByProfile: false }];
226+
const componentRows = [{ Id: '0PC000000000001', PermissionSetGroupId: GROUP_ID, PermissionSetId: PERM_SET_ID }];
227+
mockedQuery
228+
.mockResolvedValueOnce(done(permissionSetRows))
229+
.mockResolvedValueOnce(done([])) // object permissions
230+
.mockResolvedValueOnce(done([])) // field permissions
231+
.mockResolvedValueOnce(done([])) // tab settings
232+
.mockResolvedValueOnce(done([])) // assignments
233+
.mockResolvedValueOnce(done(componentRows))
234+
.mockResolvedValueOnce(done([{ Id: GROUP_ID, DeveloperName: 'GroupA', MasterLabel: 'Group A' }]))
235+
.mockResolvedValueOnce(done([]));
236+
237+
const onProgress = vi.fn();
238+
await runPermissionExport(ORG, [], [PERM_SET_ID], { onProgress });
239+
240+
const percents = onProgress.mock.calls.map(([progress]) => progress.percent as number);
241+
for (let i = 1; i < percents.length; i++) {
242+
expect(percents[i]).toBeGreaterThanOrEqual(percents[i - 1]);
243+
}
244+
expect(percents.at(-1)).toBe(100);
245+
});
183246
});

0 commit comments

Comments
 (0)