173 lines
7.9 KiB
OpenEdge ABL
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);
|
|
}
|
|
} |