327 lines
14 KiB
OpenEdge ABL
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);
|
|
}
|
|
} |