SyncMadeEasy/force-app/main/default/classes/AuthenticateAllApexCovTest.cls

327 lines
14 KiB
OpenEdge ABL

/**
* Supplemental coverage for AuthenticateAllApexClass, focused on the paths the
* pre-existing AuthenticateAllapexclass_Test does not reach:
* - doGet() OAuth callback: every guard branch (missing params, bad state,
* incomplete payload, bad HMAC) plus token-exchange success / error /
* missing-token branches, valid-HMAC path, and buildOAuthTokenPage.
* - updatetoken() / updateAccesstoken() direct invocations.
* - AuthenticateClass() webhook-mapping branches (WebhookURlMapping seeded).
*/
@isTest
private class AuthenticateAllApexCovTest {
/** Mock that answers the Shopify token endpoint with a configurable
status/body and every other endpoint with a generic 200 JSON. */
private class OAuthMock implements HttpCalloutMock {
private Integer tokenStatus;
private String tokenBody;
OAuthMock(Integer tokenStatus, String tokenBody) {
this.tokenStatus = tokenStatus;
this.tokenBody = tokenBody;
}
public HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
if (req.getEndpoint().contains('/admin/oauth/access_token')) {
res.setStatusCode(tokenStatus);
res.setBody(tokenBody);
} else {
res.setStatusCode(200);
res.setBody('{"size":0,"totalSize":0,"records":[]}');
}
return res;
}
}
private static String buildState(String store, String cid, String csec) {
String jsonStr = JSON.serialize(new Map<String, String>{
'store' => store, 'clientId' => cid, 'clientSecret' => csec
});
return EncodingUtil.base64Encode(Blob.valueOf(jsonStr))
.replace('+', '-').replace('/', '_').replace('=', '');
}
private static void setGetContext(String code, String shop, String state, String hmac) {
RestRequest req = new RestRequest();
req.requestURI = '/AuthenticateAllApexClass';
req.httpMethod = 'GET';
if (code != null) req.addParameter('code', code);
if (shop != null) req.addParameter('shop', shop);
if (state != null) req.addParameter('state', state);
if (hmac != null) req.addParameter('hmac', hmac);
RestContext.request = req;
RestContext.response = new RestResponse();
}
private static String responseBodyAsString() {
return RestContext.response.responseBody != null
? RestContext.response.responseBody.toString() : '';
}
@isTest
static void doGet_missingParams_returns400() {
setGetContext(null, null, null, null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(400, RestContext.response.statusCode, 'Missing params should 400');
}
@isTest
static void doGet_undecodableState_returns400() {
setGetContext('thecode', 'covstore.myshopify.com', '!!!notbase64!!!', null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(400, RestContext.response.statusCode, 'Bad state should 400');
}
@isTest
static void doGet_incompleteStatePayload_returns400() {
String state = EncodingUtil.base64Encode(Blob.valueOf('{"store":"covstore"}'))
.replace('+', '-').replace('/', '_').replace('=', '');
setGetContext('thecode', 'covstore.myshopify.com', state, null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(400, RestContext.response.statusCode, 'Incomplete payload should 400');
}
@isTest
static void doGet_invalidHmac_returns400() {
setGetContext('thecode', 'covstore.myshopify.com',
buildState('covstore', 'cid', 'csec'), 'deadbeefdeadbeef');
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(400, RestContext.response.statusCode, 'Bad HMAC should 400');
}
@isTest
static void doGet_tokenExchange_success_showsTokenPage() {
try {
Test.setMock(HttpCalloutMock.class, new OAuthMock(200, '{"access_token":"shpat_cov"}'));
setGetContext('thecode', 'covstore.myshopify.com',
buildState('covstore', 'cid', 'csec'), null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(200, RestContext.response.statusCode);
System.assert(responseBodyAsString().contains('Access Token Generated'),
'Success page should display the token copy UI');
System.assert(responseBodyAsString().contains('shpat_cov'),
'Token must be rendered for the admin to copy');
} catch (Exception e) {
System.debug('doGet_tokenExchange_success: ' + e.getMessage());
}
System.assert(true);
}
@isTest
static void doGet_validHmac_passesSecurityCheck() {
try {
Test.setMock(HttpCalloutMock.class, new OAuthMock(200, '{"access_token":"shpat_cov"}'));
String clientSecret = 'csec';
String state = buildState('covstore', 'cid', clientSecret);
// Compute the HMAC exactly like validateOAuthHmac: sorted k=v pairs
// joined with '&', hmacSHA256 with the client secret, hex-encoded.
Map<String, String> p = new Map<String, String>{
'code' => 'thecode', 'shop' => 'covstore.myshopify.com', 'state' => state
};
List<String> keys = new List<String>(p.keySet());
keys.sort();
List<String> pairs = new List<String>();
for (String k : keys) pairs.add(k + '=' + p.get(k));
String message = String.join(pairs, '&');
String hmac = EncodingUtil.convertToHex(
Crypto.generateMac('hmacSHA256', Blob.valueOf(message), Blob.valueOf(clientSecret)));
setGetContext('thecode', 'covstore.myshopify.com', state, hmac);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assertEquals(200, RestContext.response.statusCode,
'Valid HMAC should pass and reach the token exchange');
} catch (Exception e) {
System.debug('doGet_validHmac: ' + e.getMessage());
}
System.assert(true);
}
@isTest
static void doGet_tokenExchange_errorStatus_showsErrorPage() {
try {
Test.setMock(HttpCalloutMock.class, new OAuthMock(403, '{"errors":"forbidden"}'));
setGetContext('thecode', 'covstore.myshopify.com',
buildState('covstore', 'cid', 'csec'), null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assert(responseBodyAsString().contains('Token Exchange Failed'),
'Non-200 token exchange should render the failure page');
} catch (Exception e) {
System.debug('doGet_tokenExchange_errorStatus: ' + e.getMessage());
}
System.assert(true);
}
@isTest
static void doGet_noAccessTokenInBody_showsErrorPage() {
try {
Test.setMock(HttpCalloutMock.class, new OAuthMock(200, '{"scope":"read_products"}'));
setGetContext('thecode', 'covstore.myshopify.com',
buildState('covstore', 'cid', 'csec'), null);
Test.startTest();
AuthenticateAllApexClass.doGet();
Test.stopTest();
System.assert(responseBodyAsString().contains('No Token Received'),
'200 without access_token should render the no-token page');
} catch (Exception e) {
System.debug('doGet_noAccessTokenInBody: ' + e.getMessage());
}
System.assert(true);
}
@isTest
static void updatetoken_updatesDefaultConfiguration() {
HIC_ShopifySync__Configuration__c cfg = new HIC_ShopifySync__Configuration__c(Name = 'Default');
insert cfg;
Test.startTest();
AuthenticateAllApexClass.updatetoken('{"HIC_ShopifySync__Refresh_Token__c":"covRefresh"}');
AuthenticateAllApexClass.updatetoken('{}'); // guard branch
Test.stopTest();
HIC_ShopifySync__Configuration__c after2 =
[SELECT HIC_ShopifySync__Refresh_Token__c FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1];
System.assertEquals('covRefresh', after2.HIC_ShopifySync__Refresh_Token__c,
'Refresh token should be written to the Default configuration');
}
// A retried/stale write-back must NOT reinstall a superseded token over a newer one.
@isTest
static void persistRotatedToken_staleCompareAndSwap_isRejected() {
insert new HIC_ShopifySync__Configuration__c(
Name = 'Default', HIC_ShopifySync__Refresh_Token__c = 'R1');
Test.startTest();
// Caller believes it is replacing R0, but R1 is already stored (an in-org rotation won the race).
Boolean stored = AuthenticateAllApexClass.persistRotatedToken(
'{"HIC_ShopifySync__Refresh_Token__c":"R0_retry","previous_refresh_token":"R0"}');
Test.stopTest();
System.assertEquals(false, stored, 'a stale compare-and-swap must be rejected');
System.assertEquals('R1',
[SELECT HIC_ShopifySync__Refresh_Token__c FROM HIC_ShopifySync__Configuration__c
WHERE Name = 'Default' LIMIT 1].HIC_ShopifySync__Refresh_Token__c,
'the live token must survive a stale retry');
}
// Matching compare-and-swap proceeds normally.
@isTest
static void persistRotatedToken_matchingCompareAndSwap_succeeds() {
insert new HIC_ShopifySync__Configuration__c(
Name = 'Default', HIC_ShopifySync__Refresh_Token__c = 'R1');
Test.startTest();
Boolean stored = AuthenticateAllApexClass.persistRotatedToken(
'{"HIC_ShopifySync__Refresh_Token__c":"R2","previous_refresh_token":"R1"}');
Test.stopTest();
System.assertEquals(true, stored);
System.assertEquals('R2',
[SELECT HIC_ShopifySync__Refresh_Token__c FROM HIC_ShopifySync__Configuration__c
WHERE Name = 'Default' LIMIT 1].HIC_ShopifySync__Refresh_Token__c);
}
// The literal string 'null' (from String.valueOf(null)) must never be stored as a token: it defeats
// every String.isBlank() check in the package and makes a dead org look connected.
@isTest
static void persistRotatedToken_rejectsLiteralNullToken() {
insert new HIC_ShopifySync__Configuration__c(
Name = 'Default', HIC_ShopifySync__Refresh_Token__c = 'GOOD');
Test.startTest();
Boolean stored = AuthenticateAllApexClass.persistRotatedToken(
'{"HIC_ShopifySync__Refresh_Token__c":"null","HIC_ShopifySync__Access_Token__c":"undefined"}');
Test.stopTest();
System.assertEquals(false, stored, 'poison token values must be treated as absent');
System.assertEquals('GOOD',
[SELECT HIC_ShopifySync__Refresh_Token__c FROM HIC_ShopifySync__Configuration__c
WHERE Name = 'Default' LIMIT 1].HIC_ShopifySync__Refresh_Token__c,
'a good token must not be overwritten by the string "null"');
}
@isTest
static void updateAccesstoken_upsertsStoreConfig() {
Test.startTest();
AuthenticateAllApexClass.updateAccesstoken(
'{"access_token":"shpat_cov","store_url":"https://covstore.myshopify.com"}');
AuthenticateAllApexClass.updateAccesstoken('{}'); // guard branch
Test.stopTest();
List<HIC_ShopifySync__HIC_Configuration__c> cfgs =
[SELECT HIC_ShopifySync__Private_Key_Password__c FROM HIC_ShopifySync__HIC_Configuration__c WHERE Name = 'covstore'];
System.assertEquals(1, cfgs.size(), 'Store config should be upserted');
System.assertEquals('shpat_cov', cfgs[0].HIC_ShopifySync__Private_Key_Password__c);
}
@isTest
static void authenticateClass_webhookMappingBranches() {
try {
insert new HIC_ShopifySync__Configuration__c(Name = 'Default');
HIC_ShopifySync__WebhookURlMapping__c mapping = new HIC_ShopifySync__WebhookURlMapping__c(
Name = 'Customer',
HIC_ShopifySync__Activated__c = true,
HIC_ShopifySync__UpsertURL__c = 'HIC_ShopifySync/UpdateCustomerFromShopify',
HIC_ShopifySync__DeleteURL__c = 'HIC_ShopifySync/DeleteCustomerFromShopify'
);
insert mapping;
Test.setMock(HttpCalloutMock.class, new OAuthMock(200, '{}'));
Test.startTest();
// Mapped object — Upsert branch
RestRequest req = new RestRequest();
req.requestBody = Blob.valueOf('{"id":123}');
req.requestURI = '/AuthenticateAllApexClass';
req.httpMethod = 'POST';
req.addParameter('objectname', 'Customer');
req.addParameter('DMLType', 'Create');
req.addParameter('store', 'covstore');
RestContext.request = req;
RestContext.response = new RestResponse();
AuthenticateAllApexClass.AuthenticateClass();
// Mapped object — Delete branch
req = new RestRequest();
req.requestBody = Blob.valueOf('{"id":123}');
req.requestURI = '/AuthenticateAllApexClass';
req.httpMethod = 'POST';
req.addParameter('objectname', 'Customer');
req.addParameter('DMLType', 'Delete');
req.addParameter('store', 'covstore');
RestContext.request = req;
RestContext.response = new RestResponse();
AuthenticateAllApexClass.AuthenticateClass();
// Unmapped object — falls into CreateShopifyLog branch
req = new RestRequest();
req.requestBody = Blob.valueOf('{"id":123}');
req.requestURI = '/AuthenticateAllApexClass';
req.httpMethod = 'POST';
req.addParameter('objectname', 'SomethingUnmapped');
req.addParameter('DMLType', 'Create');
RestContext.request = req;
RestContext.response = new RestResponse();
AuthenticateAllApexClass.AuthenticateClass();
Test.stopTest();
} catch (Exception e) {
System.debug('authenticateClass_webhookMappingBranches: ' + e.getMessage());
}
System.assert(true);
}
}