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

173 lines
7.9 KiB
OpenEdge ABL

@isTest
private class TokenServiceTest {
private class Mock implements HttpCalloutMock {
Integer status;
String body;
Mock(Integer s, String b) { this.status = s; this.body = b; }
public HTTPResponse respond(HTTPRequest req) {
HttpResponse r = new HttpResponse();
r.setHeader('Content-Type', 'application/json');
r.setStatusCode(status);
r.setBody(body);
return r;
}
}
private static void seed(String access, String refresh) {
insert new HIC_ShopifySync__Configuration__c(
Name = 'Default',
HIC_ShopifySync__ClientId__c = 'cid',
HIC_ShopifySync__ClientSecret__c = 'csec',
HIC_ShopifySync__Access_Token__c = access,
HIC_ShopifySync__Refresh_Token__c = refresh
);
}
private static HIC_ShopifySync__Configuration__c reload() {
return [SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c
FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1];
}
// 200 with a rotated refresh token → both persisted, new access returned.
@isTest static void success_rotatesAndPersistsBothTokens() {
seed('old_access', 'old_refresh');
Test.setMock(HttpCalloutMock.class,
new Mock(200, '{"access_token":"new_access","refresh_token":"new_refresh"}'));
Test.startTest();
String token = TokenService.getFreshAccessToken('old_access', true);
Test.stopTest();
System.assertEquals('new_access', token);
HIC_ShopifySync__Configuration__c c = reload();
System.assertEquals('new_access', c.HIC_ShopifySync__Access_Token__c);
System.assertEquals('new_refresh', c.HIC_ShopifySync__Refresh_Token__c);
}
// Single-flight: a peer already rotated (stored access ≠ the one we failed on) → reuse, no callout.
@isTest static void singleFlight_reusesPeerToken() {
seed('peer_access', 'r');
Test.startTest();
String token = TokenService.getFreshAccessToken('my_stale_access', false);
Test.stopTest();
System.assertEquals('peer_access', token);
}
// invalid_grant → ReauthRequired, ACCESS token invalidated, REFRESH token PRESERVED.
// The refresh token is deliberately kept: blanking it is irreversible, destroys the evidence needed
// to diagnose why the connection failed, and forces a manual re-initialize even for the recoverable
// conditions that also surface as invalid_grant.
@isTest static void invalidGrant_preservesRefreshToken_reauthRequired() {
seed('a', 'r');
Test.setMock(HttpCalloutMock.class,
new Mock(400, '{"error":"invalid_grant","error_description":"expired access/refresh token"}'));
Boolean reauth = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); }
catch (TokenService.ReauthRequiredException e) { reauth = true; }
Test.stopTest();
System.assert(reauth, 'expected ReauthRequiredException');
HIC_ShopifySync__Configuration__c c = reload();
System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c,
'refresh token must be PRESERVED so the connection can self-heal without re-initialize');
System.assert(String.isBlank(c.HIC_ShopifySync__Access_Token__c),
'access token must be invalidated so every later caller re-attempts a refresh');
}
// A non-fatal invalid_grant reason (deactivated user) must NOT be treated as a dead grant.
@isTest static void invalidGrant_inactiveUser_transient_noChange() {
seed('a', 'r');
Test.setMock(HttpCalloutMock.class,
new Mock(400, '{"error":"invalid_grant","error_description":"inactive user"}'));
Boolean transientErr = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); }
catch (TokenService.TransientRefreshException e) { transientErr = true; }
catch (TokenService.ReauthRequiredException e) { System.assert(false, 'must not treat inactive user as dead grant'); }
Test.stopTest();
System.assert(transientErr, 'expected TransientRefreshException');
HIC_ShopifySync__Configuration__c c = reload();
System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c);
System.assertEquals('a', c.HIC_ShopifySync__Access_Token__c, 'nothing may change on a transient signal');
}
// invalid_grant that is actually an IP-allowlist rejection → transient, tokens untouched.
@isTest static void ipRestrict_transient_noWipe() {
seed('a', 'r');
Test.setMock(HttpCalloutMock.class,
new Mock(400, '{"error":"invalid_grant","error_description":"ip restricted"}'));
Boolean transientErr = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); }
catch (TokenService.TransientRefreshException e) { transientErr = true; }
catch (TokenService.ReauthRequiredException e) { System.assert(false, 'must NOT wipe on ip restrict'); }
Test.stopTest();
System.assert(transientErr, 'expected TransientRefreshException');
System.assertEquals('r', reload().HIC_ShopifySync__Refresh_Token__c, 'token must be untouched');
}
// 200 that omits refresh_token (rotation expected) → transient, stored tokens untouched.
@isTest static void twoHundredWithoutRefresh_transient_noChange() {
seed('a', 'r');
Test.setMock(HttpCalloutMock.class, new Mock(200, '{"access_token":"x"}'));
Boolean transientErr = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); }
catch (TokenService.TransientRefreshException e) { transientErr = true; }
Test.stopTest();
System.assert(transientErr, 'expected TransientRefreshException');
HIC_ShopifySync__Configuration__c c = reload();
System.assertEquals('r', c.HIC_ShopifySync__Refresh_Token__c);
System.assertEquals('a', c.HIC_ShopifySync__Access_Token__c);
}
// 5xx → transient, tokens untouched.
@isTest static void serverError_transient() {
seed('a', 'r');
Test.setMock(HttpCalloutMock.class, new Mock(503, 'unavailable'));
Boolean transientErr = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); }
catch (TokenService.TransientRefreshException e) { transientErr = true; }
Test.stopTest();
System.assert(transientErr);
System.assertEquals('r', reload().HIC_ShopifySync__Refresh_Token__c);
}
@isTest static void noConfig_reauthRequired() {
Boolean reauth = false;
Test.startTest();
try { TokenService.getFreshAccessToken(null, false); }
catch (TokenService.ReauthRequiredException e) { reauth = true; }
Test.stopTest();
System.assert(reauth);
}
@isTest static void blankRefresh_reauthRequired() {
seed('a', null);
Boolean reauth = false;
Test.startTest();
try { TokenService.getFreshAccessToken('a', true); } // force → skip single-flight → blank refresh
catch (TokenService.ReauthRequiredException e) { reauth = true; }
Test.stopTest();
System.assert(reauth);
}
@isTest static void tryRefresh_falseWhenReauth() {
seed('a', null);
Test.startTest();
Boolean ok = TokenService.tryRefresh('a');
Test.stopTest();
System.assertEquals(false, ok);
}
@isTest static void tryRefresh_trueOnSuccess() {
seed('old', 'r');
Test.setMock(HttpCalloutMock.class,
new Mock(200, '{"access_token":"fresh","refresh_token":"fresh_r"}'));
Test.startTest();
Boolean ok = TokenService.tryRefresh('old');
Test.stopTest();
System.assertEquals(true, ok);
System.assertEquals('fresh', reload().HIC_ShopifySync__Access_Token__c);
}
}