77 account ,
88 credential ,
99 credentialMember ,
10+ knowledgeBase ,
11+ knowledgeConnector ,
1012 member ,
1113 organization ,
1214 pendingCredentialDraft ,
@@ -29,6 +31,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2931import { env } from '@/lib/core/config/env'
3032import { closeRedisConnection } from '@/lib/core/config/redis'
3133import { processCredentialDraft } from '@/lib/credentials/draft-processor'
34+ import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits'
3235import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
3336import * as refreshCoordination from '@/lib/oauth/refresh-coordination'
3437import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
@@ -38,6 +41,7 @@ import {
3841 getRecentTerminalError ,
3942 markCredentialDead ,
4043} from '@/lib/oauth/terminal-errors'
44+ import { rememberShopifyAccountScopes } from '@/lib/shopify/privacy/installation-scopes'
4145import { GET as shopifyCallback } from '@/app/api/auth/oauth2/callback/shopify/route'
4246
4347vi . mock ( '@/lib/auth' , ( ) => authMock )
@@ -523,6 +527,64 @@ describe('Shopify offline installation tokens against PostgreSQL and HTTP', () =
523527 } )
524528 } )
525529
530+ it ( 'recovers revoked connector schedules when a claim creates a credential for an existing account in another workspace' , async ( ) => {
531+ const { completeShopifyInstall } = await import (
532+ '@/lib/credentials/application/complete-shopify-install'
533+ )
534+ const destinationId = generateId ( )
535+ const knowledgeBaseId = generateId ( )
536+ const connectorId = generateId ( )
537+ await db . insert ( workspace ) . values ( {
538+ id : destinationId ,
539+ name : 'Recovery fixture' ,
540+ ownerId : userIds [ 0 ] ,
541+ billedAccountUserId : userIds [ 0 ] ,
542+ } )
543+ await db . insert ( permissions ) . values ( {
544+ id : generateId ( ) ,
545+ userId : userIds [ 0 ] ,
546+ entityType : 'workspace' ,
547+ entityId : destinationId ,
548+ permissionType : 'admin' ,
549+ } )
550+ await db
551+ . insert ( knowledgeBase )
552+ . values ( { id : knowledgeBaseId , name : 'Recovery fixture' , userId : userIds [ 0 ] , workspaceId } )
553+ await db . insert ( knowledgeConnector ) . values ( {
554+ id : connectorId ,
555+ knowledgeBaseId,
556+ connectorType : 'shopify' ,
557+ credentialId,
558+ sourceConfig : { } ,
559+ status : 'error' ,
560+ lastSyncError : CREDENTIAL_REVOKED_SYNC_ERROR ,
561+ consecutiveFailures : 3 ,
562+ nextSyncAt : null ,
563+ } )
564+ try {
565+ const { attemptId, browserProof } = await installationCallback ( )
566+ const result = await completeShopifyInstall . execute ( {
567+ principal : createSessionPrincipal ( { userId : userIds [ 0 ] } ) ,
568+ input : { attemptId, browserProof, workspaceId : destinationId } ,
569+ } )
570+ expect ( result ) . toMatchObject ( { created : true , accountId : rowIds [ 0 ] } )
571+ const [ recovered ] = await db
572+ . select ( )
573+ . from ( knowledgeConnector )
574+ . where ( eq ( knowledgeConnector . id , connectorId ) )
575+ expect ( recovered ) . toMatchObject ( {
576+ status : 'active' ,
577+ lastSyncError : null ,
578+ consecutiveFailures : 0 ,
579+ } )
580+ expect ( recovered . nextSyncAt ?. getTime ( ) ) . toBeGreaterThanOrEqual ( testStartedAt )
581+ } finally {
582+ await db . delete ( knowledgeBase ) . where ( eq ( knowledgeBase . id , knowledgeBaseId ) )
583+ await db . delete ( permissions ) . where ( eq ( permissions . entityId , destinationId ) )
584+ await db . delete ( workspace ) . where ( eq ( workspace . id , destinationId ) )
585+ }
586+ } )
587+
526588 it . each ( [ 'valid' , 'hmac' , 'duplicate' , 'expiry' , 'domain' ] as const ) (
527589 'handles a %s Shopify-originated launch before Sim login' ,
528590 async ( condition ) => {
@@ -531,6 +593,7 @@ describe('Shopify offline installation tokens against PostgreSQL and HTTP', () =
531593 shop : condition === 'domain' ? 'attacker.example' : shopDomain ,
532594 timestamp : String ( Math . floor ( Date . now ( ) / 1000 ) - ( condition === 'expiry' ? 3600 : 0 ) ) ,
533595 } )
596+ if ( condition === 'duplicate' ) query . append ( 'shop' , 'attacker.myshopify.com' )
534597 const message = [ ...query . entries ( ) ]
535598 . sort ( ( [ a ] , [ b ] ) => ( a < b ? - 1 : a > b ? 1 : 0 ) )
536599 . map ( ( [ key , value ] ) => `${ key } =${ value } ` )
@@ -539,7 +602,6 @@ describe('Shopify offline installation tokens against PostgreSQL and HTTP', () =
539602 'hmac' ,
540603 condition === 'hmac' ? '0' . repeat ( 64 ) : hmacSha256Hex ( message , 'shopify-integration-secret' )
541604 )
542- if ( condition === 'duplicate' ) query . append ( 'shop' , 'attacker.myshopify.com' )
543605 const response = await launch (
544606 new NextRequest ( `http://localhost:3000/api/auth/shopify/install?${ query } ` ) ,
545607 undefined
@@ -760,6 +822,69 @@ describe('Shopify offline installation tokens against PostgreSQL and HTTP', () =
760822 ) . toBe ( true )
761823 } )
762824
825+ it ( 'rolls back every ownership-history page when a later association fails and retries the complete capture' , async ( ) => {
826+ const owners = Array . from ( { length : 101 } , ( ) => ( {
827+ workspaceId : generateId ( ) ,
828+ credentialId : generateId ( ) ,
829+ } ) )
830+ const lastCredentialId = [ ...owners . map ( ( owner ) => owner . credentialId ) , credentialId ]
831+ . sort ( )
832+ . at ( - 1 )
833+ await db . insert ( workspace ) . values (
834+ owners . map ( ( owner ) => ( {
835+ id : owner . workspaceId ,
836+ name : 'History page fixture' ,
837+ ownerId : userIds [ 0 ] ,
838+ billedAccountUserId : userIds [ 0 ] ,
839+ } ) )
840+ )
841+ await db . insert ( credential ) . values (
842+ owners . map ( ( owner ) => ( {
843+ id : owner . credentialId ,
844+ workspaceId : owner . workspaceId ,
845+ type : 'oauth' as const ,
846+ providerId : 'shopify' ,
847+ accountId : rowIds [ 0 ] ,
848+ displayName : 'History page fixture' ,
849+ createdBy : userIds [ 0 ] ,
850+ } ) )
851+ )
852+ await db . execute ( sql `CREATE FUNCTION shopify_fixture_reject_last_scope() RETURNS trigger LANGUAGE plpgsql AS $$
853+ BEGIN IF NEW.credential_id = TG_ARGV[0] THEN RAISE EXCEPTION 'fixture later page unavailable'; END IF; RETURN NEW; END $$` )
854+ await db . execute ( sql `CREATE TRIGGER shopify_fixture_reject_last_scope BEFORE INSERT ON shopify_installation_scope
855+ FOR EACH ROW EXECUTE FUNCTION shopify_fixture_reject_last_scope(${ sql . raw ( `'${ lastCredentialId } '` ) } )` )
856+ try {
857+ try {
858+ await expect ( rememberShopifyAccountScopes ( rowIds [ 0 ] ) ) . rejects . toThrow ( )
859+ expect (
860+ await db
861+ . select ( )
862+ . from ( shopifyInstallationScope )
863+ . where ( eq ( shopifyInstallationScope . shopDomain , shopDomain ) )
864+ ) . toHaveLength ( 0 )
865+ } finally {
866+ await db . execute (
867+ sql `DROP TRIGGER shopify_fixture_reject_last_scope ON shopify_installation_scope`
868+ )
869+ await db . execute ( sql `DROP FUNCTION shopify_fixture_reject_last_scope()` )
870+ }
871+ await rememberShopifyAccountScopes ( rowIds [ 0 ] )
872+ expect (
873+ await db
874+ . select ( )
875+ . from ( shopifyInstallationScope )
876+ . where ( eq ( shopifyInstallationScope . shopDomain , shopDomain ) )
877+ ) . toHaveLength ( 102 )
878+ } finally {
879+ await db . delete ( workspace ) . where (
880+ inArray (
881+ workspace . id ,
882+ owners . map ( ( owner ) => owner . workspaceId )
883+ )
884+ )
885+ }
886+ } )
887+
763888 it . each ( [ 'new' , 'existing' , 'reconnect' , 'organization-new' , 'organization-reconnect' ] as const ) (
764889 'preserves the %s credential and its draft when ownership-history persistence fails' ,
765890 async ( mode ) => {
0 commit comments