d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import {
|
|
dbChainMock,
|
|
dbChainMockFns,
|
|
encryptionMock,
|
|
encryptionMockFns,
|
|
resetDbChainMock,
|
|
schemaMock,
|
|
} from '@sim/testing'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockAcquireLock, mockReleaseLock, mockExtendLock } = vi.hoisted(() => ({
|
|
mockAcquireLock: vi.fn(),
|
|
mockReleaseLock: vi.fn(),
|
|
mockExtendLock: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => dbChainMock)
|
|
vi.mock('@sim/db/schema', () => schemaMock)
|
|
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
|
vi.mock('@/lib/core/config/redis', () => ({
|
|
acquireLock: mockAcquireLock,
|
|
releaseLock: mockReleaseLock,
|
|
extendLock: mockExtendLock,
|
|
}))
|
|
|
|
import {
|
|
getOrCreateOauthRow,
|
|
loadOauthRow,
|
|
setOauthRowUser,
|
|
withMcpOauthRefreshLock,
|
|
} from './storage'
|
|
|
|
describe('MCP OAuth storage', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
resetDbChainMock()
|
|
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: '{}' })
|
|
encryptionMockFns.mockEncryptSecret.mockResolvedValue({
|
|
encrypted: 'encrypted',
|
|
iv: 'iv',
|
|
})
|
|
})
|
|
|
|
it('loads OAuth state by MCP server, independent of the requesting user', async () => {
|
|
dbChainMockFns.limit.mockResolvedValueOnce([
|
|
{
|
|
id: 'oauth-row-1',
|
|
mcpServerId: 'server-1',
|
|
userId: 'authorizer-1',
|
|
workspaceId: 'workspace-1',
|
|
clientInformation: null,
|
|
tokens: null,
|
|
codeVerifier: null,
|
|
state: null,
|
|
updatedAt: new Date(),
|
|
},
|
|
])
|
|
|
|
const row = await loadOauthRow({ mcpServerId: 'server-1' })
|
|
|
|
expect(row).toMatchObject({
|
|
id: 'oauth-row-1',
|
|
mcpServerId: 'server-1',
|
|
userId: 'authorizer-1',
|
|
workspaceId: 'workspace-1',
|
|
})
|
|
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('reuses the existing workspace OAuth row instead of creating a per-user row', async () => {
|
|
dbChainMockFns.limit.mockResolvedValueOnce([
|
|
{
|
|
id: 'oauth-row-1',
|
|
mcpServerId: 'server-1',
|
|
userId: 'authorizer-1',
|
|
workspaceId: 'workspace-1',
|
|
clientInformation: null,
|
|
tokens: null,
|
|
codeVerifier: null,
|
|
state: null,
|
|
updatedAt: new Date(),
|
|
},
|
|
])
|
|
|
|
const row = await getOrCreateOauthRow({
|
|
mcpServerId: 'server-1',
|
|
userId: 'different-user',
|
|
workspaceId: 'workspace-1',
|
|
})
|
|
|
|
expect(row.id).toBe('oauth-row-1')
|
|
expect(row.userId).toBe('authorizer-1')
|
|
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('records the latest authorizing user without changing row ownership', async () => {
|
|
await setOauthRowUser('oauth-row-1', 'user-2')
|
|
|
|
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
|
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-2',
|
|
updatedAt: expect.any(Date),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('withMcpOauthRefreshLock', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockAcquireLock.mockReset()
|
|
mockReleaseLock.mockReset()
|
|
mockExtendLock.mockReset()
|
|
mockReleaseLock.mockResolvedValue(true)
|
|
mockExtendLock.mockResolvedValue(true)
|
|
})
|
|
|
|
it('serializes concurrent in-process callers, each running its own fn()', async () => {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
let active = 0
|
|
let maxActive = 0
|
|
const fn = vi.fn(async () => {
|
|
active++
|
|
maxActive = Math.max(maxActive, active)
|
|
await new Promise((r) => setTimeout(r, 1))
|
|
active--
|
|
return 'tokens'
|
|
})
|
|
|
|
const results = await Promise.all([
|
|
withMcpOauthRefreshLock('row-serial', fn),
|
|
withMcpOauthRefreshLock('row-serial', fn),
|
|
withMcpOauthRefreshLock('row-serial', fn),
|
|
])
|
|
|
|
expect(results).toEqual(['tokens', 'tokens', 'tokens'])
|
|
// Each caller gets its own fn() invocation — critical because fn() returns
|
|
// a stateful McpClient that can't be shared across consumers.
|
|
expect(fn).toHaveBeenCalledTimes(3)
|
|
// But never two at the same time within a process.
|
|
expect(maxActive).toBe(1)
|
|
expect(mockAcquireLock).toHaveBeenCalledTimes(3)
|
|
expect(mockReleaseLock).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('serializes cross-process callers: follower polls until leader releases', async () => {
|
|
// First acquire fails (another process holds it), second succeeds.
|
|
mockAcquireLock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
|
const fn = vi.fn(async () => 'fresh')
|
|
|
|
const result = await withMcpOauthRefreshLock('row-mutex', fn)
|
|
|
|
expect(result).toBe('fresh')
|
|
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
|
|
expect(fn).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('falls open when Redis is unavailable on acquire', async () => {
|
|
mockAcquireLock.mockRejectedValueOnce(new Error('Redis connection refused'))
|
|
const fn = vi.fn(async () => 'uncoordinated')
|
|
|
|
const result = await withMcpOauthRefreshLock('row-redis-down', fn)
|
|
|
|
expect(result).toBe('uncoordinated')
|
|
expect(fn).toHaveBeenCalledTimes(1)
|
|
expect(mockReleaseLock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('releases the lock even when fn throws', async () => {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
const fn = vi.fn(async () => {
|
|
throw new Error('refresh failed')
|
|
})
|
|
|
|
await expect(withMcpOauthRefreshLock('row-throws', fn)).rejects.toThrow('refresh failed')
|
|
|
|
expect(mockReleaseLock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not surface releaseLock failures to the caller', async () => {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
mockReleaseLock.mockRejectedValueOnce(new Error('release failed'))
|
|
const fn = vi.fn(async () => 'value')
|
|
|
|
const result = await withMcpOauthRefreshLock('row-release-fail', fn)
|
|
expect(result).toBe('value')
|
|
})
|
|
|
|
it('uses per-row lock keys so different rows do not serialize', async () => {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
const fn = vi.fn(async () => 'ok')
|
|
|
|
await Promise.all([withMcpOauthRefreshLock('row-a', fn), withMcpOauthRefreshLock('row-b', fn)])
|
|
|
|
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
|
|
const keys = mockAcquireLock.mock.calls.map((c) => c[0])
|
|
expect(keys).toContain('mcp:oauth:refresh:row-a')
|
|
expect(keys).toContain('mcp:oauth:refresh:row-b')
|
|
})
|
|
|
|
it('throws when the lock is held longer than the max wait (does not race)', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
// Acquire always fails — another process holds the lock with watchdog extension.
|
|
mockAcquireLock.mockResolvedValue(false)
|
|
const fn = vi.fn(async () => 'should-not-run')
|
|
|
|
const pending = withMcpOauthRefreshLock('row-deadline', fn)
|
|
// Attach the rejection expectation before draining so Vitest doesn't see
|
|
// an unhandled rejection while timers advance.
|
|
const assertion = expect(pending).rejects.toThrow(/held longer than/)
|
|
await vi.advanceTimersByTimeAsync(31_000)
|
|
await assertion
|
|
expect(fn).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('bounds the queue wait: callers stalled behind a wedged link reject without running fn', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
let resolveFirst: (value: string) => void
|
|
const hungFn = vi.fn(
|
|
() =>
|
|
new Promise<string>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
)
|
|
const queuedFn = vi.fn(async () => 'second')
|
|
|
|
const first = withMcpOauthRefreshLock('row-stall', hungFn)
|
|
const second = withMcpOauthRefreshLock('row-stall', queuedFn)
|
|
const secondAssertion = expect(second).rejects.toThrow(/stalled for/)
|
|
|
|
await vi.advanceTimersByTimeAsync(90_000)
|
|
await secondAssertion
|
|
expect(queuedFn).not.toHaveBeenCalled()
|
|
|
|
// The wedged link is untouched by the queue deadline (its own fn keeps
|
|
// the lock, protecting a possibly mid-rotation refresh) and the row
|
|
// heals once it settles — including skipping the abandoned link's fn.
|
|
resolveFirst!('first')
|
|
await expect(first).resolves.toBe('first')
|
|
|
|
await expect(withMcpOauthRefreshLock('row-stall', async () => 'healed')).resolves.toBe(
|
|
'healed'
|
|
)
|
|
expect(queuedFn).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('extends the lock TTL while fn() is running so long refreshes do not lose the lock', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
mockAcquireLock.mockResolvedValue(true)
|
|
let resolveFn: (v: string) => void
|
|
const fn = vi.fn(
|
|
() =>
|
|
new Promise<string>((resolve) => {
|
|
resolveFn = resolve
|
|
})
|
|
)
|
|
|
|
const pending = withMcpOauthRefreshLock('row-watchdog', fn)
|
|
|
|
// Advance time past two extend intervals (5s + 5s = 10s).
|
|
await vi.advanceTimersByTimeAsync(11_000)
|
|
expect(mockExtendLock.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
for (const call of mockExtendLock.mock.calls) {
|
|
expect(call[0]).toBe('mcp:oauth:refresh:row-watchdog')
|
|
}
|
|
|
|
resolveFn!('done')
|
|
await expect(pending).resolves.toBe('done')
|
|
|
|
// Watchdog must stop once fn() settles — no more extend calls.
|
|
const extendCallsAtFinish = mockExtendLock.mock.calls.length
|
|
await vi.advanceTimersByTimeAsync(20_000)
|
|
expect(mockExtendLock.mock.calls.length).toBe(extendCallsAtFinish)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|