acek-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ ---
2
+ name: sf-security-review
3
+ description: >
4
+ Use this skill for Salesforce security reviews, vulnerability analysis, and security pattern
5
+ enforcement. Trigger when the user asks to "review security", "check for vulnerabilities",
6
+ "security audit", "CRUD check", "FLS check", "sharing model", "with sharing", "SOQL injection",
7
+ "XSS in LWC", "hardcoded credentials", "permission check", or when preparing code for
8
+ production deployment and needing a security sign-off checklist. Also use when the user asks
9
+ about Salesforce Shield, encryption, audit trail, or data visibility concerns.
10
+ ---
11
+
12
+ # Salesforce Security Review Skill
13
+
14
+ ## Environment Context
15
+ - API Version: **61.0**
16
+ - Apex LSP: **offline** — use `Schema.getGlobalDescribe()` pattern (not compile-time SObject reference)
17
+ - All security decisions must be documented in the relevant CR before production deploy
18
+
19
+ ---
20
+
21
+ ## Security Layers — Salesforce Model
22
+
23
+ ```
24
+ 1. Org-Level → Login IP ranges, MFA, session settings
25
+ 2. Object-Level → Profiles + Permission Sets (CRUD)
26
+ 3. Field-Level → FLS per Profile/Permission Set
27
+ 4. Record-Level → OWD + Role Hierarchy + Sharing Rules + Manual Sharing
28
+ 5. Code-Level → with sharing, CRUD/FLS checks, no injection
29
+ ```
30
+
31
+ All five layers must be considered. Code-level checks (layer 5) cannot compensate for misconfigured org-level settings (layers 1–4).
32
+
33
+ ---
34
+
35
+ ## Apex Security — Core Patterns
36
+
37
+ ### 1. Sharing Keywords (mandatory)
38
+
39
+ ```apex
40
+ // ✅ Use on all classes — enforces record-level security
41
+ public with sharing class AccountService { }
42
+
43
+ // Only acceptable on service classes called by other with sharing classes
44
+ // AND where cross-user data access is explicitly required by business logic
45
+ public without sharing class SystemEventLogger { }
46
+
47
+ // Inherited — use for inner/helper classes that delegate sharing to caller
48
+ public inherited sharing class AccountSelector { }
49
+ ```
50
+
51
+ **Rule:** Default to `with sharing`. Document every `without sharing` with a comment explaining why.
52
+
53
+ ### 2. CRUD Check — LSP-Safe Pattern
54
+
55
+ ```apex
56
+ // ✅ CORRECT — works with offline LSP (no org metadata needed)
57
+ if (!Schema.getGlobalDescribe().get('Account').getDescribe().isCreateable()) {
58
+ throw new AuraHandledException('Insufficient access to create Account records.');
59
+ }
60
+
61
+ // ✅ For update
62
+ if (!Schema.getGlobalDescribe().get('Account').getDescribe().isUpdateable()) {
63
+ throw new AuraHandledException('Insufficient access to update Account records.');
64
+ }
65
+
66
+ // ✅ For delete
67
+ if (!Schema.getGlobalDescribe().get('Account').getDescribe().isDeletable()) {
68
+ throw new AuraHandledException('Insufficient access to delete Account records.');
69
+ }
70
+
71
+ // ❌ NEVER — compile-time reference, fails with offline LSP
72
+ if (!Account.SObjectType.getDescribe().isCreateable()) { ... }
73
+ ```
74
+
75
+ **Exception — `ContentDocumentLink`:**
76
+ Skip explicit CRUD check for this junction object — it cannot be resolved by offline LSP.
77
+ Protect via `with sharing` + try-catch wrapper instead.
78
+
79
+ ### 3. FLS Check
80
+
81
+ ```apex
82
+ // ✅ Object-level isUpdateable() — safe with offline LSP
83
+ if (!Schema.getGlobalDescribe().get('Account').getDescribe().isUpdateable()) {
84
+ throw new AuraHandledException('Insufficient access.');
85
+ }
86
+
87
+ // ⚠️ Field-level via evDescribe.fields NOT available with offline LSP
88
+ // Use object-level check only; document the limitation in code comment
89
+ ```
90
+
91
+ ### 4. Exception Wrapping
92
+
93
+ ```apex
94
+ // ✅ @AuraEnabled methods — always wrap, re-throw as AuraHandledException
95
+ @AuraEnabled
96
+ public static Result processRecord(Id recordId) {
97
+ try {
98
+ validateAccess();
99
+ return doWork(recordId);
100
+ } catch (AuraHandledException e) {
101
+ throw e; // re-throw as-is
102
+ } catch (Exception e) {
103
+ throw new AuraHandledException(e.getMessage());
104
+ }
105
+ }
106
+
107
+ // ✅ Private helper / callout methods — throw CalloutException
108
+ // Caller (@AuraEnabled method) wraps it
109
+ private static String callExternalSystem(String payload) {
110
+ HttpResponse res = new Http().send(buildRequest(payload));
111
+ if (res.getStatusCode() != 200) {
112
+ throw new CalloutException('HTTP ' + res.getStatusCode() + ': ' + res.getBody());
113
+ }
114
+ return res.getBody();
115
+ }
116
+ ```
117
+
118
+ ### 5. SOQL Injection Prevention
119
+
120
+ ```apex
121
+ // ❌ VULNERABLE — string concatenation in dynamic SOQL
122
+ String query = 'SELECT Id FROM Account WHERE Name = \'' + userInput + '\'';
123
+ List<Account> results = Database.query(query);
124
+
125
+ // ✅ SAFE — bind variables (preferred for all cases)
126
+ List<Account> results = [SELECT Id FROM Account WHERE Name = :userInput];
127
+
128
+ // ✅ SAFE — if dynamic SOQL required, escape the input
129
+ String safeInput = String.escapeSingleQuotes(userInput);
130
+ String query = 'SELECT Id FROM Account WHERE Name = \'' + safeInput + '\'';
131
+ List<Account> results = Database.query(query);
132
+ ```
133
+
134
+ **Rule:** Always prefer bind variables (`:variable`) in SOQL. Dynamic SOQL only when absolutely necessary (dynamic field names, dynamic object names). Always `escapeSingleQuotes()` on any user-supplied string in dynamic SOQL.
135
+
136
+ ### 6. No Hardcoded Values
137
+
138
+ ```apex
139
+ // ❌ NEVER — hardcoded ID, credential, or org-specific value
140
+ String endpoint = 'https://api.myorg.salesforce.com/services/data/v61.0';
141
+ Id recordTypeId = '012000000000XXXAAA';
142
+
143
+ // ✅ Named Credentials for endpoints
144
+ req.setEndpoint('callout:My_Named_Credential/resource');
145
+
146
+ // ✅ Query for Record Type ID at runtime
147
+ Id recordTypeId = Schema.getGlobalDescribe()
148
+ .get('Opportunity')
149
+ .getDescribe()
150
+ .getRecordTypeInfosByDeveloperName()
151
+ .get('Enterprise')
152
+ .getRecordTypeId();
153
+
154
+ // ✅ Custom Metadata / Custom Settings for configurable values
155
+ My_Config__mdt config = [SELECT Endpoint_URL__c FROM My_Config__mdt LIMIT 1];
156
+ ```
157
+
158
+ ---
159
+
160
+ ## LWC Security
161
+
162
+ ### 1. XSS Prevention
163
+
164
+ ```html
165
+ <!-- ✅ SAFE — lwc:text auto-escapes output -->
166
+ <p lwc:text={userGeneratedContent}></p>
167
+
168
+ <!-- ❌ DANGEROUS — renders raw HTML, XSS risk -->
169
+ <div innerHTML={userGeneratedContent}></div>
170
+ ```
171
+
172
+ ```javascript
173
+ // ❌ NEVER use innerHTML with user data
174
+ this.template.querySelector('.container').innerHTML = untrustedData;
175
+
176
+ // ✅ Use lwc:text or set textContent
177
+ element.textContent = untrustedData;
178
+ ```
179
+
180
+ ### 2. No Sensitive Data in JS / HTML
181
+
182
+ ```javascript
183
+ // ❌ NEVER — credentials, tokens, or org-specific IDs in component
184
+ const API_KEY = 'sk-1234567890abcdef';
185
+ const RECORD_TYPE_ID = '012000000000XXXAAA';
186
+
187
+ // ✅ Fetch from Apex / Custom Metadata at runtime
188
+ @wire(getConfig)
189
+ config;
190
+ ```
191
+
192
+ ### 3. Event Handling — Validate Data from Events
193
+
194
+ ```javascript
195
+ handleCustomEvent(event) {
196
+ // ✅ Validate before using event data
197
+ const { recordId } = event.detail;
198
+ if (!recordId || typeof recordId !== 'string') return;
199
+ // proceed
200
+ }
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Trigger Architecture — Security Pattern
206
+
207
+ Enforce one consistent pattern to prevent logic bypass:
208
+
209
+ ```
210
+ MyObjectTrigger.trigger → entry point only, 1-3 lines
211
+ └── MyObjectTriggerHandler.cls → routes to methods, with sharing
212
+ └── MyObjectService.cls → business logic, with sharing
213
+ └── MyObjectSelector.cls → SOQL only, inherited sharing
214
+ ```
215
+
216
+ ```apex
217
+ // Trigger — entry point only
218
+ trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
219
+ AccountTriggerHandler.handle(Trigger.operationType, Trigger.new, Trigger.oldMap);
220
+ }
221
+
222
+ // Handler — with sharing, routes by operation
223
+ public with sharing class AccountTriggerHandler {
224
+ public static void handle(
225
+ TriggerOperation op,
226
+ List<Account> newRecords,
227
+ Map<Id, Account> oldMap
228
+ ) {
229
+ if (op == TriggerOperation.BEFORE_INSERT) {
230
+ AccountService.beforeInsert(newRecords);
231
+ } else if (op == TriggerOperation.AFTER_UPDATE) {
232
+ AccountService.afterUpdate(newRecords, oldMap);
233
+ }
234
+ }
235
+ }
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Security Review Checklist
241
+
242
+ ### Apex
243
+ - [ ] All classes declare `with sharing` (or documented `without sharing` with reason)
244
+ - [ ] CRUD check present on all DML operations in `@AuraEnabled` methods
245
+ - [ ] CRUD check uses `Schema.getGlobalDescribe()` (not compile-time SObject reference)
246
+ - [ ] No SOQL injection — bind variables used; `escapeSingleQuotes()` on dynamic SOQL
247
+ - [ ] No hardcoded IDs, credentials, URLs, or org-specific values
248
+ - [ ] Named Credentials used for all external callouts
249
+ - [ ] All `@AuraEnabled` methods wrapped in try-catch → `AuraHandledException`
250
+ - [ ] Private helper methods throw `CalloutException`, not `AuraHandledException`
251
+ - [ ] `ContentDocumentLink` DML: no explicit CRUD check, protected by `with sharing` + try-catch
252
+ - [ ] Trigger follows Handler → Service → Selector architecture
253
+ - [ ] No recursive trigger execution (use static flag or Trigger Handler pattern)
254
+
255
+ ### LWC
256
+ - [ ] `lwc:text` used for dynamic content (not `innerHTML`)
257
+ - [ ] No sensitive data (API keys, IDs, credentials) in JS/HTML
258
+ - [ ] Event data validated before use
259
+ - [ ] `@api` properties documented and typed
260
+ - [ ] No direct DOM manipulation with user-supplied data
261
+
262
+ ### Org / Deployment
263
+ - [ ] Profiles: minimal baseline permissions only
264
+ - [ ] Permission Sets: additive, role-specific
265
+ - [ ] OWD: most restrictive setting that satisfies business need
266
+ - [ ] Sharing Rules: criteria documented and reviewed
267
+ - [ ] No guest user access to sensitive objects
268
+ - [ ] Named Credentials: configured in target org before deploy
269
+ - [ ] Custom Metadata: values verified in production before activation
270
+
271
+ ### Git / Source
272
+ - [ ] No Salesforce IDs in committed code
273
+ - [ ] No credentials, API keys, or session tokens in any file
274
+ - [ ] No org-specific URLs hardcoded
275
+ - [ ] `.gitignore` covers `.sf/`, `.sfdx/`, `config/user/`
276
+
277
+ ---
278
+
279
+ ## Common Vulnerabilities — Quick Reference
280
+
281
+ | Vulnerability | Where | Detection | Fix |
282
+ |---|---|---|---|
283
+ | SOQL Injection | Dynamic SOQL | String concat with user input | Bind variables / `escapeSingleQuotes()` |
284
+ | Missing CRUD check | `@AuraEnabled` DML | No `isCreateable()` before insert | Add `Schema.getGlobalDescribe()` check |
285
+ | XSS | LWC | `innerHTML` with user data | Use `lwc:text` or `textContent` |
286
+ | Hardcoded credential | Apex / JS | `apiKey = '...'` literal | Named Credential / Custom Metadata |
287
+ | `without sharing` abuse | Service class | No business justification | Change to `with sharing`; document if needed |
288
+ | Recursive trigger | Trigger | No recursion guard | Static Boolean flag in handler |
289
+ | Exposed Record IDs | LWC URL params | `recordId` passed in URL | Validate on server side, check access |
290
+ | Insecure callout | HTTP callout | Endpoint hardcoded, no Named Cred | Use Named Credentials |
291
+
292
+ ---
293
+
294
+ ## Security Review Sign-Off Template
295
+
296
+ Before every production deploy, add to CR:
297
+
298
+ ```markdown
299
+ ## Security Review
300
+
301
+ **Reviewer:** [Name]
302
+ **Date:** YYYY-MM-DD
303
+
304
+ ### Apex
305
+ - Sharing model: [with sharing on all classes ✓ / exceptions documented below]
306
+ - CRUD checks: [present on all DML ✓]
307
+ - SOQL injection risk: [none — bind variables used ✓]
308
+ - Hardcoded values: [none ✓]
309
+
310
+ ### LWC
311
+ - XSS risk: [none — lwc:text used ✓]
312
+ - Sensitive data in JS: [none ✓]
313
+
314
+ ### Exceptions & Justifications
315
+ - [ClassName] uses `without sharing` because: [reason]
316
+
317
+ ### Sign-Off
318
+ - [ ] All checklist items passed
319
+ - [ ] Exceptions documented and accepted
320
+ ```
@@ -0,0 +1,361 @@
1
+ ---
2
+ name: sf-testing
3
+ description: >
4
+ Use this skill for ANY Salesforce test-related task: writing Apex test classes, building
5
+ TestDataFactory, mocking HTTP callouts, mocking platform events, writing test assertions,
6
+ generating test coverage reports, debugging test failures, and designing test strategies.
7
+ Trigger when the user asks to "write a test class", "fix test coverage", "mock a callout",
8
+ "write a TestDataFactory", "test fails in org but passes locally", "assert this method",
9
+ or anything about Apex unit testing, test data, or code coverage. Also use when coverage
10
+ drops below 85% or when AuraHandledException assertions are involved.
11
+ ---
12
+
13
+ # Salesforce Testing Skill
14
+
15
+ ## Environment Context
16
+ - Minimum coverage: **85%** (aim for 90%+)
17
+ - API Version: **61.0**
18
+ - `AuraHandledException.getMessage()` in test context **always** returns `'Script-thrown exception'` — this is Salesforce by design, not a bug
19
+
20
+ ---
21
+
22
+ ## Test Class Structure — Standard Pattern
23
+
24
+ ```apex
25
+ @isTest
26
+ private class AccountHelperTest {
27
+
28
+ // ─── Test Data Setup ───────────────────────────────────────────────────────
29
+ @TestSetup
30
+ static void makeData() {
31
+ // Use TestDataFactory for all record creation
32
+ List<Account> accounts = TestDataFactory.createAccounts(10, true);
33
+ TestDataFactory.createContacts(accounts, 2, true);
34
+ }
35
+
36
+ // ─── Happy Path ────────────────────────────────────────────────────────────
37
+ @isTest
38
+ static void testGetAccounts_returnsActiveRecords() {
39
+ List<Account> accounts = [SELECT Id FROM Account LIMIT 1];
40
+
41
+ Test.startTest();
42
+ List<Account> result = AccountHelper.getAccounts(accounts[0].Id);
43
+ Test.stopTest();
44
+
45
+ System.assertNotEquals(null, result, 'Result should not be null');
46
+ System.assertNotEquals(0, result.size(), 'Result should contain records');
47
+ }
48
+
49
+ // ─── Edge Case ─────────────────────────────────────────────────────────────
50
+ @isTest
51
+ static void testGetAccounts_noRecords_returnsEmptyList() {
52
+ Test.startTest();
53
+ List<Account> result = AccountHelper.getAccounts(null);
54
+ Test.stopTest();
55
+
56
+ System.assertNotEquals(null, result, 'Should return empty list, not null');
57
+ System.assertEquals(0, result.size(), 'Should return empty list for null Id');
58
+ }
59
+
60
+ // ─── Exception / Permission Path ───────────────────────────────────────────
61
+ @isTest
62
+ static void testGetAccounts_insufficientPermission_throwsException() {
63
+ // Simulate restricted user if needed
64
+ // System.runAs(TestDataFactory.createRestrictedUser()) { ... }
65
+ try {
66
+ AccountHelper.restrictedMethod(null);
67
+ System.assert(false, 'Expected AuraHandledException was not thrown');
68
+ } catch (AuraHandledException e) {
69
+ // Salesforce by design: getMessage() always returns this in test context
70
+ System.assertEquals('Script-thrown exception', e.getMessage());
71
+ }
72
+ }
73
+
74
+ // ─── Bulk / Governor Limit Test ────────────────────────────────────────────
75
+ @isTest
76
+ static void testGetAccounts_bulk_200Records() {
77
+ List<Account> bulk = TestDataFactory.createAccounts(200, true);
78
+
79
+ Test.startTest();
80
+ List<Account> result = AccountHelper.processAccounts(
81
+ new Map<Id, Account>(bulk).keySet()
82
+ );
83
+ Test.stopTest();
84
+
85
+ System.assertEquals(200, result.size(), 'Should process all 200 records');
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### Rules
91
+ - **Always** use `Test.startTest()` / `Test.stopTest()` — resets governor limits and forces async execution
92
+ - **Never** use `seeAllData = true` — always create test data explicitly
93
+ - **Always** assert something — no test without `System.assert*`
94
+ - **3 minimum scenarios** per class: happy path, edge case, exception/permission
95
+ - `@TestSetup` for shared data across methods; method-level setup only when data differs per test
96
+
97
+ ---
98
+
99
+ ## TestDataFactory — Standard Pattern
100
+
101
+ Create once, reuse everywhere. File: `force-app/main/default/classes/TestDataFactory.cls`
102
+
103
+ ```apex
104
+ @isTest
105
+ public class TestDataFactory {
106
+
107
+ // ─── Accounts ──────────────────────────────────────────────────────────────
108
+ public static List<Account> createAccounts(Integer count, Boolean doInsert) {
109
+ List<Account> accounts = new List<Account>();
110
+ for (Integer i = 0; i < count; i++) {
111
+ accounts.add(new Account(
112
+ Name = 'Test Account ' + i,
113
+ Industry = 'Technology',
114
+ Phone = '021000000' + i
115
+ ));
116
+ }
117
+ if (doInsert) insert accounts;
118
+ return accounts;
119
+ }
120
+
121
+ // ─── Contacts ──────────────────────────────────────────────────────────────
122
+ public static List<Contact> createContacts(
123
+ List<Account> accounts, Integer perAccount, Boolean doInsert
124
+ ) {
125
+ List<Contact> contacts = new List<Contact>();
126
+ for (Account acc : accounts) {
127
+ for (Integer i = 0; i < perAccount; i++) {
128
+ contacts.add(new Contact(
129
+ FirstName = 'Test',
130
+ LastName = 'Contact ' + i,
131
+ AccountId = acc.Id,
132
+ Email = 'test' + i + '@' + acc.Id + '.com'
133
+ ));
134
+ }
135
+ }
136
+ if (doInsert) insert contacts;
137
+ return contacts;
138
+ }
139
+
140
+ // ─── Users ─────────────────────────────────────────────────────────────────
141
+ public static User createUser(String profileName, Boolean doInsert) {
142
+ Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
143
+ User u = new User(
144
+ FirstName = 'Test',
145
+ LastName = 'User ' + profileName,
146
+ Email = 'testuser_' + profileName.replace(' ', '') + '@test.com',
147
+ Username = 'testuser_' + profileName.replace(' ', '')
148
+ + Datetime.now().getTime() + '@test.com',
149
+ Alias = 'tuser',
150
+ TimeZoneSidKey = 'Asia/Jakarta',
151
+ LocaleSidKey = 'id_ID',
152
+ EmailEncodingKey = 'UTF-8',
153
+ LanguageLocaleKey = 'en_US',
154
+ ProfileId = p.Id
155
+ );
156
+ if (doInsert) insert u;
157
+ return u;
158
+ }
159
+
160
+ // ─── Custom Objects ────────────────────────────────────────────────────────
161
+ // Add your org-specific objects here following the same pattern:
162
+ // public static List<Project__c> createProjects(Integer count, Boolean doInsert) { ... }
163
+ }
164
+ ```
165
+
166
+ ### Factory Rules
167
+ - `doInsert` parameter on every method — caller decides whether to insert
168
+ - Unique field values: use loop index + timestamp/Id to avoid duplicate errors
169
+ - **Never** hardcode record IDs
170
+ - Add org-specific objects in the `Custom Objects` section
171
+ - Username must be globally unique — always append `Datetime.now().getTime()`
172
+
173
+ ---
174
+
175
+ ## Mocking HTTP Callouts
176
+
177
+ ### Step 1 — Create Mock Implementation
178
+ ```apex
179
+ @isTest
180
+ global class MyCalloutMock implements HttpCalloutMock {
181
+ global HTTPResponse respond(HTTPRequest req) {
182
+ HttpResponse res = new HttpResponse();
183
+ res.setHeader('Content-Type', 'application/json');
184
+ res.setStatusCode(200);
185
+ res.setBody('{"status":"success","id":"12345"}');
186
+ return res;
187
+ }
188
+ }
189
+
190
+ // For error scenarios:
191
+ @isTest
192
+ global class MyCalloutMockError implements HttpCalloutMock {
193
+ global HTTPResponse respond(HTTPRequest req) {
194
+ HttpResponse res = new HttpResponse();
195
+ res.setStatusCode(500);
196
+ res.setBody('{"error":"Internal Server Error"}');
197
+ return res;
198
+ }
199
+ }
200
+ ```
201
+
202
+ ### Step 2 — Use in Test
203
+ ```apex
204
+ @isTest
205
+ static void testCallout_success() {
206
+ Test.setMock(HttpCalloutMock.class, new MyCalloutMock());
207
+
208
+ Test.startTest();
209
+ String result = MyService.callExternalSystem('payload');
210
+ Test.stopTest();
211
+
212
+ System.assertEquals('12345', result, 'Should return ID from mock response');
213
+ }
214
+
215
+ @isTest
216
+ static void testCallout_serverError_throwsException() {
217
+ Test.setMock(HttpCalloutMock.class, new MyCalloutMockError());
218
+
219
+ Test.startTest();
220
+ try {
221
+ MyService.callExternalSystem('payload');
222
+ System.assert(false, 'Expected exception not thrown');
223
+ } catch (CalloutException e) {
224
+ System.assert(e.getMessage().contains('500'), 'Should surface HTTP 500 error');
225
+ }
226
+ Test.stopTest();
227
+ }
228
+ ```
229
+
230
+ ### Rules
231
+ - `Test.setMock()` must be called **before** `Test.startTest()`
232
+ - Always test both success and failure mock paths
233
+ - For multiple endpoints, use `StaticResourceCalloutMock` or a routing mock that checks `req.getEndpoint()`
234
+
235
+ ---
236
+
237
+ ## Mocking Platform Events
238
+
239
+ ```apex
240
+ @isTest
241
+ static void testPlatformEventPublish() {
242
+ Test.startTest();
243
+ MyService.publishEvent('payload');
244
+ // Fire platform event delivery
245
+ Test.getEventBus().deliver();
246
+ Test.stopTest();
247
+
248
+ // Assert downstream effect (e.g. record created by subscriber trigger)
249
+ List<MyLog__c> logs = [SELECT Id FROM MyLog__c];
250
+ System.assertEquals(1, logs.size(), 'Event subscriber should have created a log');
251
+ }
252
+ ```
253
+
254
+ ---
255
+
256
+ ## System.runAs — Testing Permission Boundaries
257
+
258
+ ```apex
259
+ @isTest
260
+ static void testRestrictedUser_cannotDelete() {
261
+ User restrictedUser = TestDataFactory.createUser('Standard User', true);
262
+ Account acc = TestDataFactory.createAccounts(1, true)[0];
263
+
264
+ System.runAs(restrictedUser) {
265
+ Test.startTest();
266
+ try {
267
+ delete acc;
268
+ System.assert(false, 'Standard user should not be able to delete Account');
269
+ } catch (DmlException e) {
270
+ System.assert(
271
+ e.getMessage().contains('insufficient access'),
272
+ 'Should throw insufficient access error'
273
+ );
274
+ }
275
+ Test.stopTest();
276
+ }
277
+ }
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Assertion Best Practices
283
+
284
+ ```apex
285
+ // ✅ Always include a descriptive message (3rd param)
286
+ System.assertEquals(expected, actual, 'Descriptive message of what was expected');
287
+ System.assertNotEquals(null, result, 'Result should not be null');
288
+ System.assert(result.size() > 0, 'List should not be empty after processing');
289
+
290
+ // ✅ For AuraHandledException — always expect 'Script-thrown exception'
291
+ System.assertEquals('Script-thrown exception', e.getMessage());
292
+
293
+ // ✅ Assert specific fields, not just count
294
+ System.assertEquals('Active', result[0].Status__c, 'Status should be Active after processing');
295
+
296
+ // ❌ Never assert without message
297
+ System.assertEquals(5, result.size()); // Bad — no context when it fails
298
+
299
+ // ❌ Never test with catch-all that swallows the error
300
+ try {
301
+ SomeClass.method();
302
+ } catch (Exception e) {
303
+ // empty catch — useless
304
+ }
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Coverage Strategy
310
+
311
+ ### Minimum per component
312
+ | Component | Required | Target |
313
+ |---|---|---|
314
+ | Apex Class (`@AuraEnabled`) | 85% | 90%+ |
315
+ | Apex Trigger | 85% | 95%+ |
316
+ | Batch / Schedulable / Queueable | 85% | 90%+ |
317
+
318
+ ### Run tests via SF CLI
319
+ ```bash
320
+ # Run specific test class
321
+ sf apex run test \
322
+ --class-names AccountHelperTest \
323
+ --target-org <alias> \
324
+ --result-format human \
325
+ --code-coverage
326
+
327
+ # Run multiple test classes (deployment-style)
328
+ sf apex run test \
329
+ --class-names AccountHelperTest,OpportunityServiceTest \
330
+ --target-org <alias> \
331
+ --result-format json \
332
+ --code-coverage
333
+
334
+ # Check coverage of specific class
335
+ sf apex run test \
336
+ --class-names AccountHelperTest \
337
+ --target-org <alias> \
338
+ --result-format human \
339
+ --code-coverage \
340
+ | grep -A 5 "AccountHelper"
341
+ ```
342
+
343
+ ### When coverage is below 85%
344
+ 1. Identify uncovered lines: run test with `--code-coverage` and review output
345
+ 2. Check: are there `if/else` branches not tested?
346
+ 3. Check: are there exception paths not triggered?
347
+ 4. Add targeted test methods — do **not** reduce test quality just to hit the number
348
+ 5. Never use `@SuppressWarnings` to bypass coverage requirements
349
+
350
+ ---
351
+
352
+ ## Common Test Failures & Fixes
353
+
354
+ | Error | Cause | Fix |
355
+ |---|---|---|
356
+ | `MIXED_DML_OPERATION` | Inserting Setup object (User) and non-Setup object in same context | Wrap User insert in `System.runAs(new User())` |
357
+ | `UNABLE_TO_LOCK_ROW` | Parallel tests updating same record | Use `@TestSetup` + unique records per test |
358
+ | `Too many SOQL queries: 101` | SOQL in loop not caught by test | Add bulk test (200 records), fix the source |
359
+ | `Script-thrown exception` in `getMessage()` | `AuraHandledException` — expected behavior | Assert this exact string — it's correct |
360
+ | Test passes locally, fails in org | `seeAllData=true` dependency or hardcoded IDs | Remove `seeAllData`, use `TestDataFactory` |
361
+ | `Callout not allowed` | HTTP callout without `Test.setMock()` | Add mock before `Test.startTest()` |