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.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/bin/cli.js +300 -0
- package/package.json +33 -0
- package/skills/sf-admin/SKILL.md +245 -0
- package/skills/sf-ba/SKILL.md +297 -0
- package/skills/sf-data-migration/SKILL.md +374 -0
- package/skills/sf-dev/SKILL.md +328 -0
- package/skills/sf-devops/SKILL.md +372 -0
- package/skills/sf-ideation/SKILL.md +132 -0
- package/skills/sf-security-review/SKILL.md +320 -0
- package/skills/sf-testing/SKILL.md +361 -0
|
@@ -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()` |
|