Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@
import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag';
import type {GuidedSetupData} from '@userActions/Report';
import {buildInviteToRoomOnyxData, completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report';
import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from '@userActions/Transaction';
import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI, stringifyWaypointsForAPI} from '@userActions/Transaction';
import {removeDraftTransaction, removeDraftTransactions, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit';
import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow';
import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow';
Expand Down Expand Up @@ -771,7 +771,7 @@
betas: OnyxEntry<OnyxTypes.Beta[]>;
};

type GetTrackExpenseInformationTransactionParams = {

Check warning on line 774 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
comment: string;
amount: number;
currency: string;
Expand Down Expand Up @@ -871,7 +871,7 @@
chatReportID: string | undefined;
chatReport: OnyxEntry<OnyxTypes.Report> | undefined;
transactionID: string | undefined;
reportAction: OnyxTypes.ReportAction;

Check warning on line 874 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
iouReport: OnyxEntry<OnyxTypes.Report>;
chatIOUReport: OnyxEntry<OnyxTypes.Report>;
transactions: OnyxCollection<OnyxTypes.Transaction>;
Expand All @@ -885,7 +885,7 @@

type DeleteMoneyRequestFunctionParams = {
transactionID: string | undefined;
reportAction: OnyxTypes.ReportAction;

Check warning on line 888 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
transactions: OnyxCollection<OnyxTypes.Transaction>;
violations: OnyxCollection<OnyxTypes.TransactionViolations>;
iouReport: OnyxEntry<OnyxTypes.Report>;
Expand All @@ -894,7 +894,7 @@
isSingleTransactionView?: boolean;
transactionIDsPendingDeletion?: string[];
selectedTransactionIDs?: string[];
allTransactionViolationsParam: OnyxCollection<OnyxTypes.TransactionViolations>;

Check warning on line 897 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
currentUserAccountID: number;
};

Expand All @@ -908,7 +908,7 @@
userBillingGraceEndPeriods: OnyxCollection<OnyxTypes.BillingGraceEndPeriod>;
paymentPolicyID?: string;
full?: boolean;
activePolicy?: OnyxEntry<OnyxTypes.Policy>;

Check warning on line 911 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
policy?: OnyxEntry<OnyxTypes.Policy>;
betas: OnyxEntry<OnyxTypes.Beta[]>;
isSelfTourViewed: boolean | undefined;
Expand All @@ -921,7 +921,7 @@
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
allTransactions = {};

Check warning on line 924 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
return;
}

Expand All @@ -930,7 +930,7 @@
});

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 933 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -940,7 +940,7 @@

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,

Check warning on line 943 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -949,7 +949,7 @@
}

allTransactionViolations = value;
},

Check warning on line 952 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
});

let allPolicyTags: OnyxCollection<OnyxTypes.PolicyTagLists> = {};
Expand All @@ -957,7 +957,7 @@
key: ONYXKEYS.COLLECTION.POLICY_TAGS,
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {

Check warning on line 960 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
allPolicyTags = {};
return;
}
Expand Down Expand Up @@ -4611,8 +4611,7 @@
const transactionDetails = getTransactionDetails(updatedTransaction, undefined, undefined, allowNegative);

if (transactionDetails?.waypoints) {
// This needs to be a JSON string since we're sending this to the MapBox API
transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints);
transactionDetails.waypoints = stringifyWaypointsForAPI(transactionDetails.waypoints as WaypointCollection);
}

const dataToIncludeInParams: Partial<TransactionDetails> = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges));
Expand Down Expand Up @@ -5159,8 +5158,7 @@
const transactionDetails = getTransactionDetails(updatedTransaction);

if (transactionDetails?.waypoints) {
// This needs to be a JSON string since we're sending this to the MapBox API
transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints);
transactionDetails.waypoints = stringifyWaypointsForAPI(transactionDetails.waypoints as WaypointCollection);
}

const dataToIncludeInParams: Partial<TransactionDetails> = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges));
Expand Down Expand Up @@ -5749,7 +5747,9 @@
parentReportNextStep,
}: UpdateMoneyRequestDistanceParams) {
const transactionChanges: TransactionChanges = {
...(waypoints && {waypoints: sanitizeRecentWaypoints(waypoints)}),
// Don't sanitize waypoints here - keep all fields for Onyx optimistic data (e.g., keyForList)
// Sanitization happens when building API params
...(waypoints && {waypoints}),
routes,
...(distance && {distance}),
...(odometerStart !== undefined && {odometerStart}),
Expand Down Expand Up @@ -6413,7 +6413,7 @@

const isDistanceRequest = isDistanceRequestTransactionUtils(transaction);
const transactionWaypoints = getWaypoints(transaction);
const sanitizedWaypointsForBulk = transactionWaypoints ? JSON.stringify(sanitizeRecentWaypoints(transactionWaypoints)) : undefined;
const sanitizedWaypointsForBulk = transactionWaypoints ? stringifyWaypointsForAPI(transactionWaypoints) : undefined;

const convertParams: ConvertTrackedExpenseToRequestParams = {
payerParams: {
Expand Down Expand Up @@ -6697,7 +6697,7 @@

const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined;

const sanitizedWaypoints = waypoints ? JSON.stringify(sanitizeRecentWaypoints(waypoints)) : undefined;
const sanitizedWaypoints = waypoints ? stringifyWaypointsForAPI(waypoints) : undefined;

// If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function
const isMoneyRequestReport = isMoneyRequestReportReportUtils(report);
Expand Down Expand Up @@ -7496,7 +7496,7 @@

// Pass an open receipt so the distance expense will show a map with the route optimistically
const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, name: 'receipt-generic.png'} : receipt;
const sanitizedWaypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined;
const sanitizedWaypoints = validWaypoints ? stringifyWaypointsForAPI(validWaypoints) : undefined;

const retryParams: CreateTrackExpenseParams = {
...params,
Expand Down Expand Up @@ -8440,7 +8440,7 @@

let parameters: CreateDistanceRequestParams;
let onyxData: OnyxData<BuildOnyxDataForMoneyRequestKeys | typeof ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE | typeof ONYXKEYS.NVP_RECENT_WAYPOINTS | typeof ONYXKEYS.GPS_DRAFT_DETAILS>;
const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeRecentWaypoints(validWaypoints) : null;
const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeWaypointsForAPI(validWaypoints) : null;
if (iouType === CONST.IOU.TYPE.SPLIT) {
const {
splitData,
Expand Down
45 changes: 35 additions & 10 deletions src/libs/actions/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,23 +347,47 @@ function getOnyxDataForRouteRequest(
}

/**
* Sanitizes the waypoints by removing the pendingAction property.
* Sanitizes the waypoints data to only include allowed fields for API requests.
* Only keeps: name (optional), address, lat, lng
*
* @param waypoints - The collection of waypoints to sanitize.
* @returns The sanitized collection of waypoints.
* @returns The sanitized collection of waypoints with only allowed fields.
*/
function sanitizeRecentWaypoints(waypoints: WaypointCollection): WaypointCollection {
function sanitizeWaypointsForAPI(waypoints: WaypointCollection): WaypointCollection {
return Object.entries(waypoints).reduce((acc: WaypointCollection, [key, waypoint]) => {
if ('pendingAction' in waypoint) {
const {pendingAction, ...rest} = waypoint;
acc[key] = rest;
} else {
acc[key] = waypoint;
if (!waypoint) {
return acc;
}

const sanitizedWaypoint: Record<string, string | number> = {};

if (waypoint.name !== undefined) {
sanitizedWaypoint.name = waypoint.name;
}
if (waypoint.address !== undefined) {
sanitizedWaypoint.address = waypoint.address;
}
if (waypoint.lat !== undefined) {
sanitizedWaypoint.lat = waypoint.lat;
}
if (waypoint.lng !== undefined) {
sanitizedWaypoint.lng = waypoint.lng;
}

acc[key] = sanitizedWaypoint;
return acc;
}, {});
}

/**
* Sanitizes waypoints and serializes them to a JSON string for API params.
* Preserves keyForList and other Onyx-only fields by sanitizing at the serialization boundary
* rather than when building transactionChanges.
*/
function stringifyWaypointsForAPI(waypoints: WaypointCollection): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are introduces stringifyWaypointsForAPI specifically to encapsulate JSON.stringify(sanitizeWaypointsForAPI(...)), and uses it in 2 places (getUpdateMoneyRequestParams and getUpdateTrackExpenseParams). However, 3 other call sites still use the manual JSON.stringify(sanitizeWaypointsForAPI(...)) pattern:

[IOU/index.ts:6416] — convertBulkTrackedExpensesToIOU
[IOU/index.ts:6700]— requestMoney
[IOU/index.ts:7499] — trackExpense

These should use stringifyWaypointsForAPI(...) for consistency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot please address this and update the code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this and confirmed there are 4 call sites still using the manual JSON.stringify(sanitizeWaypointsForAPI(...)) pattern instead of stringifyWaypointsForAPI(...):

  1. src/libs/actions/Transaction.ts:399getRoute
  2. src/libs/actions/IOU/index.ts:6416convertBulkTrackedExpensesToIOU
  3. src/libs/actions/IOU/index.ts:6700requestMoney
  4. src/libs/actions/IOU/index.ts:7499trackExpense

The fix for each:

Transaction.ts:399 — direct replacement:

-        waypoints: JSON.stringify(sanitizeWaypointsForAPI(waypoints)),
+        waypoints: stringifyWaypointsForAPI(waypoints),

IOU/index.ts:6416, 6700, 7499 — keep the null guard, replace the stringify call:

-const sanitizedWaypointsForBulk = transactionWaypoints ? JSON.stringify(sanitizeWaypointsForAPI(transactionWaypoints)) : undefined;
+const sanitizedWaypointsForBulk = transactionWaypoints ? stringifyWaypointsForAPI(transactionWaypoints) : undefined;

(Same pattern for the other two sites.)

Also, once the sanitizeWaypointsForAPI import is no longer directly used in IOU/index.ts, it should be removed from the import statement at line 243.


⚠️ I can't push changes to this PR because I didn't open it and I'm not listed as an assignee. To have me make this fix, add me to the PR assignees and then retry your request.

This comment was marked as duplicate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot Okay, now you are an assignee. Please go ahead and implement this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Pushed 588d3a1 which replaces all 4 remaining manual JSON.stringify(sanitizeWaypointsForAPI(...)) calls with stringifyWaypointsForAPI(...):

  • src/libs/actions/Transaction.ts:399getRoute
  • src/libs/actions/IOU/index.ts:6416convertBulkTrackedExpensesToIOU
  • src/libs/actions/IOU/index.ts:6700requestMoney
  • src/libs/actions/IOU/index.ts:7499trackExpense

Note: sanitizeWaypointsForAPI is still imported in IOU/index.ts because it's used directly (without JSON.stringify) at line 8443.

return JSON.stringify(sanitizeWaypointsForAPI(waypoints));
}

/**
* Gets the route for a set of waypoints
* Used so we can generate a map view of the provided waypoints
Expand All @@ -372,7 +396,7 @@ function sanitizeRecentWaypoints(waypoints: WaypointCollection): WaypointCollect
function getRoute(transactionID: string, waypoints: WaypointCollection, routeType: TransactionState = CONST.TRANSACTION.STATE.CURRENT) {
const parameters: GetRouteParams = {
transactionID,
waypoints: JSON.stringify(sanitizeRecentWaypoints(waypoints)),
waypoints: stringifyWaypointsForAPI(waypoints),
};

let command;
Expand Down Expand Up @@ -1682,7 +1706,8 @@ export {
setReviewDuplicatesKey,
abandonReviewDuplicateTransactions,
openDraftDistanceExpense,
sanitizeRecentWaypoints,
sanitizeWaypointsForAPI,
stringifyWaypointsForAPI,
getLastModifiedExpense,
revert,
changeTransactionsReport,
Expand Down
122 changes: 121 additions & 1 deletion tests/unit/TransactionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import OnyxUtils from 'react-native-onyx/dist/OnyxUtils';
import useOnyx from '@hooks/useOnyx';
import {changeTransactionsReport, dismissDuplicateTransactionViolation, markAsCash, saveWaypoint} from '@libs/actions/Transaction';
import {changeTransactionsReport, dismissDuplicateTransactionViolation, markAsCash, sanitizeWaypointsForAPI, saveWaypoint} from '@libs/actions/Transaction';
import DateUtils from '@libs/DateUtils';
import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils';
import type {buildOptimisticNextStep} from '@libs/NextStepUtils';
Expand Down Expand Up @@ -1101,6 +1101,126 @@ describe('Transaction', () => {
});
});

describe('sanitizeWaypointsForAPI', () => {
it('should only include allowed fields (name, address, lat, lng)', () => {
// Given waypoints with extra fields that should be stripped out
const waypointsWithExtraFields = {
waypoint0: {
name: 'Start Location',
address: '123 Main St',
lat: 40.7128,
lng: -74.006,
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'US',
street: '123 Main St',
street2: 'Apt 4B',
keyForList: 'unique-key-1',
pendingAction: 'add',
extraField: 'should be removed',
},
waypoint1: {
address: '456 Oak Ave',
lat: 40.7589,
lng: -73.9851,
city: 'New York',
state: 'NY',
zipCode: '10002',
keyForList: 'unique-key-2',
anotherExtraField: 'should also be removed',
},
};

// When sanitizing the waypoints
// Test intentionally passes extra fields not in WaypointCollection to verify they are stripped
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithExtraFields as any);

// Then only allowed fields should remain
expect(sanitizedWaypoints.waypoint0).toEqual({
name: 'Start Location',
address: '123 Main St',
lat: 40.7128,
lng: -74.006,
});

// waypoint1 has no name field, so it should not be included
expect(sanitizedWaypoints.waypoint1).toEqual({
address: '456 Oak Ave',
lat: 40.7589,
lng: -73.9851,
});
});

it('should handle waypoints with only some allowed fields', () => {
// Given waypoints with only address
const waypointsWithPartialFields = {
waypoint0: {
address: 'Partial Address Only',
},
};

// When sanitizing the waypoints
// Test uses a partial waypoint object to verify sanitization handles missing fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithPartialFields as any);

// Then only the address should be present
expect(sanitizedWaypoints.waypoint0).toEqual({
address: 'Partial Address Only',
});
});

it('should handle empty waypoints', () => {
// Given empty waypoints
const emptyWaypoints = {};

// When sanitizing the waypoints
const sanitizedWaypoints = sanitizeWaypointsForAPI(emptyWaypoints);

// Then the result should also be empty
expect(sanitizedWaypoints).toEqual({});
});

it('should skip null waypoint entries without crashing', () => {
// Given waypoints where some entries are null (can happen during rollback of failed distance edits)
const waypointsWithNulls = {
waypoint0: {
address: '123 Main St',
lat: 40.7128,
lng: -74.006,
},
waypoint1: null,
waypoint2: {
address: '789 Pine Rd',
lat: 40.73,
lng: -73.99,
},
};

// When sanitizing the waypoints
// Null entries can occur at runtime even though WaypointCollection type doesn't include null
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithNulls as any);

// Then null entries should be dropped and valid entries sanitized
expect(sanitizedWaypoints).toEqual({
waypoint0: {
address: '123 Main St',
lat: 40.7128,
lng: -74.006,
},
waypoint2: {
address: '789 Pine Rd',
lat: 40.73,
lng: -73.99,
},
});
expect(sanitizedWaypoints.waypoint1).toBeUndefined();
});
});

describe('markAsCash', () => {
it('should optimistically remove RTER violation and add dismissed violation report action', async () => {
// Given a transaction with an RTER violation
Expand Down
Loading