Skip to content

Commit 57bc872

Browse files
committed
fix(shopify): recover accounts and capture all ownership history
1 parent 3948df0 commit 57bc872

3 files changed

Lines changed: 169 additions & 40 deletions

File tree

‎apps/sim/lib/credentials/application/complete-shopify-install.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,7 @@ export const completeShopifyInstall = defineAuthorizedWorkspaceUseCase({
5050
if (!result.changed) return
5151
try {
5252
await clearOAuthRefreshDeadFlag(getShopifyRefreshScope(result.shopDomain))
53-
if (!result.created)
54-
await resumeConnectorsAfterCredentialReconnect(result.accountId, new Date())
53+
await resumeConnectorsAfterCredentialReconnect(result.accountId, new Date())
5554
} catch {
5655
logger.warn('Shopify credential saved; connection recovery notification unavailable')
5756
}

‎apps/sim/lib/oauth/shopify.integration.ts‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
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 }
2931
import { env } from '@/lib/core/config/env'
3032
import { closeRedisConnection } from '@/lib/core/config/redis'
3133
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
34+
import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits'
3235
import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
3336
import * as refreshCoordination from '@/lib/oauth/refresh-coordination'
3437
import { 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'
4145
import { GET as shopifyCallback } from '@/app/api/auth/oauth2/callback/shopify/route'
4246

4347
vi.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) => {

‎apps/sim/lib/shopify/privacy/installation-scopes.ts‎

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -94,46 +94,51 @@ export async function rememberShopifyCredentialScope(
9494
)
9595
}
9696

97-
/** Captures existing OAuth associations before completing a legacy credential draft. */
97+
/** Atomically captures existing OAuth associations in bounded pages before completing a legacy draft. */
9898
export async function rememberShopifyAccountScopes(accountId: string): Promise<void> {
99-
const [installation] = await db
100-
.select({
101-
shopId: account.accountId,
102-
shopDomain: account.idToken,
103-
})
104-
.from(account)
105-
.where(and(eq(account.id, accountId), eq(account.providerId, 'shopify')))
106-
.limit(1)
107-
if (!installation?.shopDomain) return
108-
const { values } = requireConfiguredOAuthClient('shopify')
109-
let cursor: string | undefined
110-
while (true) {
111-
const associations = await db
99+
await db.transaction(async (tx) => {
100+
const [installation] = await tx
112101
.select({
113-
credentialId: credential.id,
114-
workspaceId: credential.workspaceId,
115-
organizationId: credential.organizationId,
102+
shopId: account.accountId,
103+
shopDomain: account.idToken,
116104
})
117-
.from(credential)
118-
.where(
119-
and(
120-
eq(credential.accountId, accountId),
121-
eq(credential.type, 'oauth'),
122-
cursor ? gt(credential.id, cursor) : undefined
105+
.from(account)
106+
.where(and(eq(account.id, accountId), eq(account.providerId, 'shopify')))
107+
.limit(1)
108+
if (!installation?.shopDomain) return
109+
const { values } = requireConfiguredOAuthClient('shopify')
110+
let cursor: string | undefined
111+
while (true) {
112+
const associations = await tx
113+
.select({
114+
credentialId: credential.id,
115+
workspaceId: credential.workspaceId,
116+
organizationId: credential.organizationId,
117+
})
118+
.from(credential)
119+
.where(
120+
and(
121+
eq(credential.accountId, accountId),
122+
eq(credential.type, 'oauth'),
123+
cursor ? gt(credential.id, cursor) : undefined
124+
)
123125
)
124-
)
125-
.orderBy(asc(credential.id))
126-
.limit(100)
127-
for (const association of associations) {
128-
await rememberShopifyInstallationScope({
129-
...association,
130-
appClientId: values.SHOPIFY_CLIENT_ID,
131-
shopId: installation.shopId,
132-
shopDomain: installation.shopDomain.toLowerCase(),
133-
accountId,
134-
})
126+
.orderBy(asc(credential.id))
127+
.limit(100)
128+
for (const association of associations) {
129+
await rememberShopifyInstallationScope(
130+
{
131+
...association,
132+
appClientId: values.SHOPIFY_CLIENT_ID,
133+
shopId: installation.shopId,
134+
shopDomain: installation.shopDomain.toLowerCase(),
135+
accountId,
136+
},
137+
tx
138+
)
139+
}
140+
if (associations.length < 100) return
141+
cursor = associations[associations.length - 1].credentialId
135142
}
136-
if (associations.length < 100) return
137-
cursor = associations[associations.length - 1].credentialId
138-
}
143+
})
139144
}

0 commit comments

Comments
 (0)