Enhanced Transaction Security Apex Testing

Writing robust tests is an engineering best practice to ensure that your code does what you expect and to find errors before your users and customers do. It’s even more important to write tests for your transaction security policy’s Apex code because it executes during critical user actions in your Salesforce org. For example, a bug in your LoginEvent policy that’s not caught during testing can result in locking your users out of your org, a situation best avoided.
Available in both Salesforce Classic (not available in all orgs) and Lightning Experience.
Available in: Enterprise, Unlimited, and Developer Editions

Requires Salesforce Shield or Salesforce Event Monitoring add-on subscriptions.


Use API version 47.0 or later when writing Apex tests for enhanced transaction security policies.

Warning

When you test your Apex code by simulating a set of conditions, you are by definition writing unit tests. But writing unit tests isn’t enough. Work with your business and security teams to understand all your use cases. Then create a comprehensive test plan that mimics your actual users’ experience using test data in a sandbox environment. The test plan typically includes both manual testing and automated testing using external tools such as Selenium.

Let’s look at some sample unit tests to get you started. Here’s the Apex policy that we want to test.

global class LeadExportEventCondition implements TxnSecurity.EventCondition {
    public boolean evaluate(SObject event) {
        switch on event{
            when ApiEvent apiEvent {
                return evaluate(apiEvent.QueriedEntities, apiEvent.RowsProcessed);
            }
            when ReportEvent reportEvent {
                return evaluate(reportEvent.QueriedEntities, reportEvent.RowsProcessed);
            }
            when ListViewEvent listViewEvent {
                return evaluate(listViewEvent.QueriedEntities, listViewEvent.RowsProcessed);
            }
            when null {
                 return false;   
            }
            when else {
                return false;
            }
        }
    }

    private boolean evaluate(String queriedEntities, Decimal rowsProcessed){
        if (queriedEntities.contains('Lead') && rowsProcessed > 2000){
            return true;
        }
        return false;
    }
}

Plan and Write Tests

Before we start writing tests, let’s outline the positive and negative use cases that our test plan covers.

Table 1. Positive Test Cases
If the evaluate method receives... And ... Then the evaluate method returns...
An ApiEvent object The ApiEvent has Lead in its QueriedEntities field and a number greater than 2000 in its RowsProcessed field true
A ReportEvent object The ReportEvent has Lead in its QueriedEntities field and a number greater than 2000 in its RowsProcessed field true
A ListViewEvent object The ListViewEvent has Lead in its QueriedEntities field and a number greater than 2000 in its RowsProcessed field true
Any event object The event doesn’t have Lead in its QueriedEntities field and has a number greater than 2000 in its RowsProcessed field false
Any event object The event has Lead in its QueriedEntities field and has a number less than or equal to 2000 in its RowsProcessed field false
Any event object The event doesn’t have Lead in its QueriedEntities field and has a number less than or equal to 2000 in its RowsProcessed field false
Table 2. Negative Test Cases
If the evaluate method receives... And ... Then the evaluate method returns...
A LoginEvent object (no condition) false
A null value (no condition) false
An ApiEvent object The QueriedEntities field is null false
A ReportEvent object The RowsProcessed field is null false

Here’s the Apex testing code that implements all of these use cases.

/**
 * Tests for the LeadExportEventCondition class, to make sure that our Transaction Security Apex 
 * logic handles events and event field values as expected.
 **/
 @isTest
 public class LeadExportEventConditionTest {
 
    /**
     * ------------ POSITIVE TEST CASES ------------
     ** /
 
     /**
      * Positive test case 1: If an ApiEvent has Lead as a queried entity and more than 2000 rows 
      * processed, then the evaluate method of our policy's Apex should return true.
      **/ 
      static testMethod void testApiEventPositiveTestCase() {
          // set up our event and its field values
          ApiEvent testEvent = new ApiEvent();
          testEvent.QueriedEntities = 'Account, Lead';
          testEvent.RowsProcessed = 2001;
          
          // test that the Apex returns true for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assert(eventCondition.evaluate(testEvent));   
      }
     
     /**
      * Positive test case 2: If a ReportEvent has Lead as a queried entity and more than 2000 rows 
      * processed, then the evaluate method of our policy's Apex should return true.
      **/ 
      static testMethod void testReportEventPositiveTestCase() {
          // set up our event and its field values
          ReportEvent testEvent = new ReportEvent();
          testEvent.QueriedEntities = 'Account, Lead';
          testEvent.RowsProcessed = 2001;
          
          // test that the Apex returns true for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assert(eventCondition.evaluate(testEvent));   
      }
     
     /**
      * Positive test case 3: If a ListViewEvent has Lead as a queried entity and more than 2000 rows 
      * processed, then the evaluate method of our policy's Apex should return true.
      **/ 
      static testMethod void testListViewEventPositiveTestCase() {
          // set up our event and its field values
          ListViewEvent testEvent = new ListViewEvent();
          testEvent.QueriedEntities = 'Account, Lead';
          testEvent.RowsProcessed = 2001;
          
          // test that the Apex returns true for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assert(eventCondition.evaluate(testEvent));   
      }
     
     /**
      * Positive test case 4: If an event does not have Lead as a queried entity and has more 
      * than 2000 rows processed, then the evaluate method of our policy's Apex 
      * should return false.
      **/ 
      static testMethod void testOtherQueriedEntityPositiveTestCase() {
          // set up our event and its field values
          ApiEvent testEvent = new ApiEvent();
          testEvent.QueriedEntities = 'Account';
          testEvent.RowsProcessed = 2001;
          
          // test that the Apex returns false for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(testEvent));   
      }
      
    /**
      * Positive test case 5: If an event has Lead as a queried entity and does not have 
      * more than 2000 rows processed, then the evaluate method of our policy's Apex 
      * should return false.
      **/ 
      static testMethod void testFewerRowsProcessedPositiveTestCase() {
          // set up our event and its field values
          ReportEvent testEvent = new ReportEvent();
          testEvent.QueriedEntities = 'Account, Lead';
          testEvent.RowsProcessed = 2000;
          
          // test that the Apex returns false for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(testEvent));   
      }
      
    /**
      * Positive test case 6: If an event does not have Lead as a queried entity and does not have 
      * more than 2000 rows processed, then the evaluate method of our policy's Apex 
      * should return false.
      **/ 
      static testMethod void testNoConditionsMetPositiveTestCase() {
          // set up our event and its field values
          ListViewEvent testEvent = new ListViewEvent();
          testEvent.QueriedEntities = 'Account';
          testEvent.RowsProcessed = 2000;
          
          // test that the Apex returns false for this event
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(testEvent));   
      }
      
      /**
       * ------------ NEGATIVE TEST CASES ------------
       **/
     
     /**
      * Negative test case 1: If an event is a type other than ApiEvent, ReportEvent, or ListViewEvent,
      * then the evaluate method of our policy's Apex should return false.
      **/
      static testMethod void testOtherEventObject() {
          LoginEvent loginEvent = new LoginEvent();
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(loginEvent));   
      } 
 
     /**
      * Negative test case 2: If an event is null, then the evaluate method of our policy's
      * Apex should return false.
      **/
      static testMethod void testNullEventObject() {
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(null));   
      } 
     
     /**
      * Negative test case 3: If an event has a null QueriedEntities value, then the evaluate method 
      * of our policy's Apex should return false.
      **/
      static testMethod void testNullQueriedEntities() {
          ApiEvent testEvent = new ApiEvent(); 
          testEvent.QueriedEntities = null;
          testEvent.RowsProcessed = 2001;
          
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(testEvent));   
      }
     
     /**
      * Negative test case 4: If an event has a null RowsProcessed value, then the evaluate method 
      * of our policy's Apex should return false.
      **/
      static testMethod void testNullRowsProcessed() {
          ReportEvent testEvent = new ReportEvent(); 
          testEvent.QueriedEntities = 'Account, Lead';
          testEvent.RowsProcessed = null;
          
          LeadExportEventCondition  eventCondition = new LeadExportEventCondition();
          System.assertEquals(false, eventCondition.evaluate(testEvent));   
      } 
 }

Refine the Policy Code After Running the Tests

Let's say you run the tests and the testNullQueriedEntities test case fails with the error System.NullPointerException: Attempt to de-reference a null object. Great news, the tests identified an area of the transaction security policy that isn't checking for unexpected or null values. Because policies run during critical org operations, make sure that the policies fail gracefully if there's an error so that they don't block important functionality.

Here's how to update the evaluate method in the Apex class to handle those null values gracefully.

private boolean evaluate(String queriedEntities, Decimal rowsProcessed) {
    boolean containsLead = queriedEntities != null ? queriedEntities.contains('Lead')
    if (containsLead && rowsProcessed > 2000){
        return true;
    }
    return false;
}

We’ve changed the code so that before performing the .contains operation on the queriedEntities variable, we first check if the value is null. This change ensures that the code doesn’t dereference a null object.

In general, when you encounter unexpected values or situations in your Apex code, you have two options. Determine what is best for your users when deciding which option to choose:

  • Ignore the values or situation and return false so that the policy doesn't trigger.
  • Fail-close the operation by returning true.

Advanced Example

Here's a more complex Apex policy that uses SOQL queries to get the profile of the user who is attempting to log in.

global class ProfileIdentityEventCondition implements TxnSecurity.EventCondition {

    // For these powerful profiles, let's prompt users to complete 2FA
    private Set<String> PROFILES_TO_MONITOR = new Set<String> { 
        'System Administrator', 
        'Custom Admin Profile'
    };
    
    public boolean evaluate(SObject event) {
        LoginEvent loginEvent = (LoginEvent) event;
        String userId = loginEvent.UserId;
        
        // get the Profile name from the current users profileId
        Profile profile = [SELECT Name FROM Profile WHERE Id IN 
                    (SELECT profileId FROM User WHERE Id = :userId)];
        
        // check if the name of the Profile is one of the ones we want to monitor
        if (PROFILES_TO_MONITOR.contains(profile.Name)) {
            return true;
        }
        
        return false;
    }   
 }

Here's our test plan for positive test cases:

    • If the user attempting to log in has the profile we’re interested in monitoring, then the evaluate method returns true.
    • If the user attempting to log in doesn't have the profile we’re interested in monitoring, then the evaluate method returns false.

And here’s our plan for negative test cases:

    • If querying for the Profile object throws an exception, then the evaluate method returns false.
    • If querying for the Profile object returns null, then the evaluate method returns false.

Because every Salesforce user is always assigned a profile, there's no need to create a negative test for it. It’s also not possible to create actual tests for the two negative test cases. We take care of them by updating the policy itself. But we explicitly list the use cases in our plan to make sure that we cover many different situations.

The positive test cases rely on the results of SQQL queries. To ensure that these queries execute correctly, we must also create some test data. Let's look at the test code.

/**
 * Tests for the ProfileIdentityEventCondition class, to make sure that our 
 * Transaction Security Apex logic handles events and event field values as expected.
 **/
 @isTest
 public class ProfileIdentityEventConditionTest {
 
    /**
     * ------------ POSITIVE TEST CASES ------------
     ** /
 
     /**
      * Positive test case 1: Evaluate will return true when user has the "System 
      * Administrator" profile.
      **/ 
      static testMethod void testUserWithSysAdminProfile() {
          // insert a User for our test which has the System Admin profile
          Profile profile = [SELECT Id FROM Profile WHERE Name='System Administrator'];
          assertOnProfile(profile.id, true); 
      }

     /**
      * Positive test case 2: Evaluate will return true when the user has the "Custom
      * Admin Profile"
      **/ 
      static testMethod void testUserWithCustomProfile() {
          // insert a User for our test which has the System Admin profile
          Profile profile = [SELECT Id FROM Profile WHERE Name='Custom Admin Profile'];
          assertOnProfile(profile.id, true);
      }
      
     /**
      * Positive test case 3: Evalueate will return false when user doesn't have
      * a profile we're interested in. In this case we'll be using a profile called
      * 'Standard User'.
      **/ 
      static testMethod void testUserWithSomeProfile() {
          // insert a User for our test which has the System Admin profile
          Profile profile = [SELECT Id FROM Profile WHERE Name='Standard User'];
          assertOnProfile(profile.id, false);
      }
      
      /**
       * Helper to assert on different profiles.
       **/
      static void assertOnProfile(String profileId, boolean expected){
          User user = createUserWithProfile(profileId);
          insert user;
      
          // set up our event and its field values
          LoginEvent testEvent = new LoginEvent();
          testEvent.UserId = user.Id;
          
          // test that the Apex returns true for this event
          ProfileIdentityEventCondition  eventCondition = new ProfileIdentityEventCondition();
          System.assertEquals(expected, eventCondition.evaluate(testEvent));  
      }
      
      /**
       * Helper to create a user with the given profileId.
       **/
      static User createUserWithProfile(String profileId){
          // Usernames have to be unique.
          String username = 'ProfileIdentityEventCondition@Test.com';
          
          User user = new User(Alias = 'standt', Email='standarduser@testorg.com', 
          EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US', 
          LocaleSidKey='en_US', ProfileId = profileId, 
          TimeZoneSidKey='America/Los_Angeles', UserName=username);
          return user;
      }
 }

Let’s handle the two negative test cases by updating the transaction security policy code to check for exceptions or null results when querying the Profile object.

global class ProfileIdentityEventCondition implements TxnSecurity.EventCondition {

    // For these powerful profiles, let's prompt users to complete 2FA
    private Set<String> PROFILES_TO_MONITOR = new Set<String> { 
        'System Administrator', 
        'Custom Admin Profile'
    };
    
    public boolean evaluate(SObject event) {
        try{
            LoginEvent loginEvent = (LoginEvent) event;
            String userId = loginEvent.UserId;
            
            // get the Profile name from the current users profileId
            Profile profile = [SELECT Name FROM Profile WHERE Id IN 
                        (SELECT profileId FROM User WHERE Id = :userId)];
            
            if (profile == null){
                return false;
            }
            
            // check if the name of the Profile is one of the ones we want to monitor
            if (PROFILES_TO_MONITOR.contains(profile.Name)) {
                return true;
            }
            return false;
        } catch(Exception ex){
            System.debug('Exception: ' + ex);
            return false;   
        }
    }   
 }