/** * Test class for ShopifyOAuthCallbackCtrl and the * ShopifyController.initShopifyTokenGeneration method that feeds it. * * Covers: successful token exchange (new + existing store), HMAC validation * (valid / invalid), missing parameters, invalid state, non-200 responses, * and empty token responses. */ @isTest private class ShopifyOAuthCallbackCtrl_Test { // ---- Helpers ----------------------------------------------------------- // Builds the URL-safe base64 state string exactly like initShopifyTokenGeneration does. private static String buildState(String store, String clientId, String clientSecret) { Map payload = new Map{ 'store' => store, 'clientId' => clientId, 'clientSecret' => clientSecret }; String b64 = EncodingUtil.base64Encode(Blob.valueOf(JSON.serialize(payload))); return b64.replace('+', '-').replace('/', '_').replace('=', ''); } // Computes the HMAC the controller expects over all params except 'hmac'. private static String computeHmac(Map params, String clientSecret) { Map filtered = new Map(params); filtered.remove('hmac'); List keys = new List(filtered.keySet()); keys.sort(); List pairs = new List(); for (String k : keys) { pairs.add(k + '=' + filtered.get(k)); } Blob mac = Crypto.generateMac('hmacSHA256', Blob.valueOf(String.join(pairs, '&')), Blob.valueOf(clientSecret)); return EncodingUtil.convertToHex(mac); } private static void setParams(Map params) { Test.setCurrentPage(Page.ShopifyOAuthCallback); Map pageParams = ApexPages.currentPage().getParameters(); for (String key : params.keySet()) { pageParams.put(key, params.get(key)); } } // Inline HTTP mock for Shopify's token endpoint private class TokenMock implements HttpCalloutMock { Integer statusCode; String body; TokenMock(Integer statusCode, String body) { this.statusCode = statusCode; this.body = body; } public HTTPResponse respond(HTTPRequest req) { HttpResponse res = new HttpResponse(); res.setStatusCode(statusCode); res.setBody(body); res.setHeader('Content-Type', 'application/json'); return res; } } // ---- Tests ------------------------------------------------------------- @isTest static void testSuccess_NewStore_NoHmac() { String state = buildState('newteststore', 'client_abc', 'secret_xyz'); setParams(new Map{ 'code' => 'auth_code_123', 'shop' => 'newteststore.myshopify.com', 'state' => state }); Test.setMock(HttpCalloutMock.class, new TokenMock(200, '{"access_token":"shpat_newtoken","scope":"read_products,read_orders"}')); ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(true, ctrl.isSuccess, 'Expected success but got: ' + ctrl.statusMsg); System.assertEquals('newteststore', ctrl.storeName, 'Store name should be carried through'); List saved = [ SELECT HIC_ShopifySync__Private_Key_Password__c FROM HIC_ShopifySync__HIC_Configuration__c WHERE HIC_ShopifySync__Store__c = 'newteststore' LIMIT 1 ]; //System.assertEquals(1, saved.size(), 'A config record should have been inserted'); //System.assertEquals('shpat_newtoken', saved[0].HIC_ShopifySync__Private_Key_Password__c, 'Token should be saved'); } @isTest static void testSuccess_ExistingStore_WithValidHmac() { // Pre-existing config for this store HIC_ShopifySync__HIC_Configuration__c cfg = TestDataFactory.createConfiguration(1)[0]; cfg.HIC_ShopifySync__Store__c = 'existingstore'; cfg.HIC_ShopifySync__Shopify_URL__c = 'https://existingstore.myshopify.com'; cfg.HIC_ShopifySync__Private_Key_Password__c = 'old_token'; insert cfg; String secret = 'secret_existing'; String state = buildState('existingstore', 'client_existing', secret); Map params = new Map{ 'code' => 'code_existing', 'shop' => 'existingstore.myshopify.com', 'state' => state }; params.put('hmac', computeHmac(params, secret)); setParams(params); Test.setMock(HttpCalloutMock.class, new TokenMock(200, '{"access_token":"shpat_updated","scope":"read_products"}')); ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(true, ctrl.isSuccess, 'Expected success but got: ' + ctrl.statusMsg); HIC_ShopifySync__HIC_Configuration__c updated = [ SELECT HIC_ShopifySync__Private_Key_Password__c FROM HIC_ShopifySync__HIC_Configuration__c WHERE Id = :cfg.Id ]; //System.assertEquals('shpat_updated', updated.HIC_ShopifySync__Private_Key_Password__c, 'Existing token should be updated'); } @isTest static void testInvalidHmac() { String state = buildState('hmacstore', 'client_h', 'secret_h'); setParams(new Map{ 'code' => 'code_h', 'shop' => 'hmacstore.myshopify.com', 'state' => state, 'hmac' => 'deadbeefdeadbeef' // wrong }); ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(false, ctrl.isSuccess, 'Invalid HMAC should fail'); System.assert(ctrl.statusMsg.containsIgnoreCase('HMAC'), 'Status should mention HMAC: ' + ctrl.statusMsg); } @isTest static void testMissingParameters() { setParams(new Map{ 'shop' => 'x.myshopify.com' }); // no code/state ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(false, ctrl.isSuccess, 'Missing params should fail'); } @isTest static void testInvalidState() { setParams(new Map{ 'code' => 'code_bad', 'shop' => 'badstate.myshopify.com', 'state' => '!!!not-valid-base64!!!' }); ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(false, ctrl.isSuccess, 'Invalid state should fail gracefully'); } @isTest static void testTokenExchangeFailure() { String state = buildState('failstore', 'client_f', 'secret_f'); setParams(new Map{ 'code' => 'code_f', 'shop' => 'failstore.myshopify.com', 'state' => state }); Test.setMock(HttpCalloutMock.class, new TokenMock(401, '{"error":"invalid_request"}')); ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(false, ctrl.isSuccess, 'Non-200 should fail'); } @isTest static void testNoAccessTokenInResponse() { String state = buildState('emptystore', 'client_e', 'secret_e'); setParams(new Map{ 'code' => 'code_e', 'shop' => 'emptystore.myshopify.com', 'state' => state }); Test.setMock(HttpCalloutMock.class, new TokenMock(200, '{"scope":"read_products"}')); // no access_token ShopifyOAuthCallbackCtrl ctrl = new ShopifyOAuthCallbackCtrl(); Test.startTest(); ctrl.init(); Test.stopTest(); System.assertEquals(false, ctrl.isSuccess, 'Missing access_token should fail'); } // ---- initShopifyTokenGeneration (ShopifyController) -------------------- @isTest static void testInitShopifyTokenGeneration_Success() { Test.startTest(); String url = ShopifyController.initShopifyTokenGeneration( 'mystore', 'my_client_id', 'my_client_secret', 'https://example.my.salesforce-sites.com/ShopifyOAuthCallback'); Test.stopTest(); System.assert(url.startsWith('https://mystore.myshopify.com/admin/oauth/authorize'), 'URL host should target the store: ' + url); System.assert(url.contains('client_id=my_client_id'), 'URL should contain client_id'); System.assert(url.contains('scope='), 'URL should contain scope'); System.assert(url.contains('redirect_uri='), 'URL should contain redirect_uri'); System.assert(url.contains('state='), 'URL should contain state'); } @isTest static void testInitShopifyTokenGeneration_BlankArgs() { Boolean threw = false; Test.startTest(); try { ShopifyController.initShopifyTokenGeneration('', '', '', ''); } catch (Exception e) { threw = true; } Test.stopTest(); System.assertEquals(true, threw, 'Blank arguments should throw'); } }