@@ -1338,6 +1338,244 @@ describe('Auth Adapter features', () => {
13381338 expect ( user . get ( 'authData' ) ) . toEqual ( { adapterB : { id : 'test' } } ) ;
13391339 } ) ;
13401340
1341+ it ( 'should unlink a code-based auth provider without triggering adapter validation' , async ( ) => {
1342+ const mockUserId = 'gpgamesUser123' ;
1343+ const mockAccessToken = 'mockAccessToken' ;
1344+
1345+ const otherAdapter = {
1346+ validateAppId : ( ) => Promise . resolve ( ) ,
1347+ validateAuthData : ( ) => Promise . resolve ( ) ,
1348+ } ;
1349+
1350+ mockFetch ( [
1351+ {
1352+ url : 'https://oauth2.googleapis.com/token' ,
1353+ method : 'POST' ,
1354+ response : {
1355+ ok : true ,
1356+ json : ( ) => Promise . resolve ( { access_token : mockAccessToken } ) ,
1357+ } ,
1358+ } ,
1359+ {
1360+ url : `https://www.googleapis.com/games/v1/players/${ mockUserId } ` ,
1361+ method : 'GET' ,
1362+ response : {
1363+ ok : true ,
1364+ json : ( ) => Promise . resolve ( { playerId : mockUserId } ) ,
1365+ } ,
1366+ } ,
1367+ ] ) ;
1368+
1369+ await reconfigureServer ( {
1370+ auth : {
1371+ gpgames : {
1372+ clientId : 'testClientId' ,
1373+ clientSecret : 'testClientSecret' ,
1374+ } ,
1375+ otherAdapter,
1376+ } ,
1377+ } ) ;
1378+
1379+ // Sign up with username/password, then link providers
1380+ const user = new Parse . User ( ) ;
1381+ await user . signUp ( { username : 'gpgamesTestUser' , password : 'password123' } ) ;
1382+
1383+ // Link gpgames code-based provider
1384+ await user . save ( {
1385+ authData : {
1386+ gpgames : { id : mockUserId , code : 'authCode123' , redirect_uri : 'https://example.com/callback' } ,
1387+ } ,
1388+ } ) ;
1389+
1390+ // Link a second provider
1391+ await user . save ( { authData : { otherAdapter : { id : 'other1' } } } ) ;
1392+
1393+ // Reset fetch spy to track calls during unlink
1394+ global . fetch . calls . reset ( ) ;
1395+
1396+ // Unlink gpgames by setting authData to null; should not call beforeFind / external APIs
1397+ const sessionToken = user . getSessionToken ( ) ;
1398+ await user . save ( { authData : { gpgames : null } } , { sessionToken } ) ;
1399+
1400+ // No external HTTP calls should have been made during unlink
1401+ expect ( global . fetch . calls . count ( ) ) . toBe ( 0 ) ;
1402+
1403+ // Verify gpgames was removed while the other provider remains
1404+ await user . fetch ( { useMasterKey : true } ) ;
1405+ const authData = user . get ( 'authData' ) ;
1406+ expect ( authData ) . toBeDefined ( ) ;
1407+ expect ( authData . gpgames ) . toBeUndefined ( ) ;
1408+ expect ( authData . otherAdapter ) . toEqual ( { id : 'other1' } ) ;
1409+ } ) ;
1410+
1411+ it ( 'should unlink one code-based provider while echoing back another unchanged' , async ( ) => {
1412+ const gpgamesUserId = 'gpgamesUser1' ;
1413+ const instagramUserId = 'igUser1' ;
1414+
1415+ // Mock gpgames API for initial login
1416+ mockFetch ( [
1417+ {
1418+ url : 'https://oauth2.googleapis.com/token' ,
1419+ method : 'POST' ,
1420+ response : {
1421+ ok : true ,
1422+ json : ( ) => Promise . resolve ( { access_token : 'gpgamesToken' } ) ,
1423+ } ,
1424+ } ,
1425+ {
1426+ url : `https://www.googleapis.com/games/v1/players/${ gpgamesUserId } ` ,
1427+ method : 'GET' ,
1428+ response : {
1429+ ok : true ,
1430+ json : ( ) => Promise . resolve ( { playerId : gpgamesUserId } ) ,
1431+ } ,
1432+ } ,
1433+ ] ) ;
1434+
1435+ await reconfigureServer ( {
1436+ auth : {
1437+ gpgames : {
1438+ clientId : 'testClientId' ,
1439+ clientSecret : 'testClientSecret' ,
1440+ } ,
1441+ instagram : {
1442+ clientId : 'testClientId' ,
1443+ clientSecret : 'testClientSecret' ,
1444+ redirectUri : 'https://example.com/callback' ,
1445+ } ,
1446+ } ,
1447+ } ) ;
1448+
1449+ // Login with gpgames
1450+ const user = await Parse . User . logInWith ( 'gpgames' , {
1451+ authData : { id : gpgamesUserId , code : 'gpCode1' , redirect_uri : 'https://example.com/callback' } ,
1452+ } ) ;
1453+ const sessionToken = user . getSessionToken ( ) ;
1454+
1455+ // Mock instagram API for linking
1456+ mockFetch ( [
1457+ {
1458+ url : 'https://api.instagram.com/oauth/access_token' ,
1459+ method : 'POST' ,
1460+ response : {
1461+ ok : true ,
1462+ json : ( ) => Promise . resolve ( { access_token : 'igToken' } ) ,
1463+ } ,
1464+ } ,
1465+ {
1466+ url : `https://graph.instagram.com/me?fields=id&access_token=igToken` ,
1467+ method : 'GET' ,
1468+ response : {
1469+ ok : true ,
1470+ json : ( ) => Promise . resolve ( { id : instagramUserId } ) ,
1471+ } ,
1472+ } ,
1473+ ] ) ;
1474+
1475+ // Link instagram as second provider
1476+ await user . save (
1477+ { authData : { instagram : { id : instagramUserId , code : 'igCode1' } } } ,
1478+ { sessionToken }
1479+ ) ;
1480+
1481+ // Fetch to get current authData (afterFind strips credentials, leaving only { id })
1482+ await user . fetch ( { sessionToken } ) ;
1483+ const currentAuthData = user . get ( 'authData' ) ;
1484+ expect ( currentAuthData . gpgames ) . toBeDefined ( ) ;
1485+ expect ( currentAuthData . instagram ) . toBeDefined ( ) ;
1486+
1487+ // Reset fetch spy
1488+ global . fetch . calls . reset ( ) ;
1489+
1490+ // Unlink gpgames while echoing back instagram unchanged — the common client pattern:
1491+ // fetch current state, spread it, set the one to unlink to null
1492+ user . set ( 'authData' , { ...currentAuthData , gpgames : null } ) ;
1493+ await user . save ( null , { sessionToken } ) ;
1494+
1495+ // No external HTTP calls during unlink (no code exchange for unchanged instagram)
1496+ expect ( global . fetch . calls . count ( ) ) . toBe ( 0 ) ;
1497+
1498+ // Verify gpgames removed, instagram preserved
1499+ await user . fetch ( { useMasterKey : true } ) ;
1500+ const finalAuthData = user . get ( 'authData' ) ;
1501+ expect ( finalAuthData ) . toBeDefined ( ) ;
1502+ expect ( finalAuthData . gpgames ) . toBeUndefined ( ) ;
1503+ expect ( finalAuthData . instagram ) . toBeDefined ( ) ;
1504+ expect ( finalAuthData . instagram . id ) . toBe ( instagramUserId ) ;
1505+ } ) ;
1506+
1507+ it ( 'should reject changing an existing code-based provider id without credentials' , async ( ) => {
1508+ const mockUserId = 'gpgamesUser123' ;
1509+ const mockAccessToken = 'mockAccessToken' ;
1510+
1511+ mockFetch ( [
1512+ {
1513+ url : 'https://oauth2.googleapis.com/token' ,
1514+ method : 'POST' ,
1515+ response : {
1516+ ok : true ,
1517+ json : ( ) => Promise . resolve ( { access_token : mockAccessToken } ) ,
1518+ } ,
1519+ } ,
1520+ {
1521+ url : `https://www.googleapis.com/games/v1/players/${ mockUserId } ` ,
1522+ method : 'GET' ,
1523+ response : {
1524+ ok : true ,
1525+ json : ( ) => Promise . resolve ( { playerId : mockUserId } ) ,
1526+ } ,
1527+ } ,
1528+ ] ) ;
1529+
1530+ await reconfigureServer ( {
1531+ auth : {
1532+ gpgames : {
1533+ clientId : 'testClientId' ,
1534+ clientSecret : 'testClientSecret' ,
1535+ } ,
1536+ } ,
1537+ } ) ;
1538+
1539+ // Sign up and link gpgames with valid credentials
1540+ const user = new Parse . User ( ) ;
1541+ await user . save ( {
1542+ authData : {
1543+ gpgames : { id : mockUserId , code : 'authCode123' , redirect_uri : 'https://example.com/callback' } ,
1544+ } ,
1545+ } ) ;
1546+ const sessionToken = user . getSessionToken ( ) ;
1547+
1548+ // Attempt to change gpgames id without credentials (no code or access_token)
1549+ await expectAsync (
1550+ user . save ( { authData : { gpgames : { id : 'differentUserId' } } } , { sessionToken } )
1551+ ) . toBeRejectedWith (
1552+ jasmine . objectContaining ( { message : jasmine . stringContaining ( 'code is required' ) } )
1553+ ) ;
1554+ } ) ;
1555+
1556+ it ( 'should reject linking a new code-based provider with only an id and no credentials' , async ( ) => {
1557+ await reconfigureServer ( {
1558+ auth : {
1559+ gpgames : {
1560+ clientId : 'testClientId' ,
1561+ clientSecret : 'testClientSecret' ,
1562+ } ,
1563+ } ,
1564+ } ) ;
1565+
1566+ // Sign up with username/password (no gpgames linked)
1567+ const user = new Parse . User ( ) ;
1568+ await user . signUp ( { username : 'linkTestUser' , password : 'password123' } ) ;
1569+ const sessionToken = user . getSessionToken ( ) ;
1570+
1571+ // Attempt to link gpgames with only { id } — no code or access_token
1572+ await expectAsync (
1573+ user . save ( { authData : { gpgames : { id : 'victimUserId' } } } , { sessionToken } )
1574+ ) . toBeRejectedWith (
1575+ jasmine . objectContaining ( { message : jasmine . stringContaining ( 'code is required' ) } )
1576+ ) ;
1577+ } ) ;
1578+
13411579 it ( 'should handle multiple providers: add one while another remains unchanged (code-based)' , async ( ) => {
13421580 await reconfigureServer ( {
13431581 auth : {
0 commit comments