Skip to main content
Glama

Sentry MCP

Official
by getsentry
helpers.test.ts8.78 kB
import { describe, it, expect, vi, beforeEach } from "vitest"; import type { TokenExchangeCallbackOptions } from "@cloudflare/workers-oauth-provider"; import { tokenExchangeCallback, refreshAccessToken } from "./helpers"; import type { WorkerProps } from "../types"; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; describe("tokenExchangeCallback", () => { const mockEnv = { SENTRY_CLIENT_ID: "test-client-id", SENTRY_CLIENT_SECRET: "test-client-secret", SENTRY_HOST: "sentry.io", }; beforeEach(() => { vi.clearAllMocks(); }); it("should skip non-refresh_token grant types", async () => { const options: TokenExchangeCallbackOptions = { grantType: "authorization_code", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: {} as WorkerProps, }; const result = await tokenExchangeCallback(options, mockEnv); expect(result).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); }); it("should return undefined when no refresh token in props", async () => { const options: TokenExchangeCallbackOptions = { grantType: "refresh_token", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: { id: "user-id", name: "Test User", accessToken: "old-access-token", // No refreshToken } as WorkerProps, }; await expect( tokenExchangeCallback(options, mockEnv), ).resolves.toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); }); it("should reuse cached token when it has sufficient TTL remaining", async () => { const futureExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes from now const options: TokenExchangeCallbackOptions = { grantType: "refresh_token", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: { id: "user-id", name: "Test User", accessToken: "cached-access-token", refreshToken: "refresh-token", accessTokenExpiresAt: futureExpiry, } as WorkerProps, }; const result = await tokenExchangeCallback(options, mockEnv); // Should not call upstream API expect(mockFetch).not.toHaveBeenCalled(); // Should return existing props with calculated TTL expect(result).toBeDefined(); expect(result?.newProps).toEqual(options.props); expect(result?.accessTokenTTL).toBeGreaterThan(0); expect(result?.accessTokenTTL).toBeLessThanOrEqual(600); // Max 10 minutes }); it("should refresh token when cached token is close to expiry", async () => { const nearExpiry = Date.now() + 1 * 60 * 1000; // 1 minute from now (less than 2 min safety window) const options: TokenExchangeCallbackOptions = { grantType: "refresh_token", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: { id: "user-id", name: "Test User", accessToken: "old-access-token", refreshToken: "old-refresh-token", accessTokenExpiresAt: nearExpiry, } as WorkerProps, }; // Mock successful refresh response mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 3600, expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), token_type: "bearer", user: { id: "user-id", name: "Test User", email: "test@example.com", }, scope: "org:read project:read", }), }); const result = await tokenExchangeCallback(options, mockEnv); // Should call upstream API expect(mockFetch).toHaveBeenCalledWith( "https://sentry.io/oauth/token/", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "Content-Type": "application/x-www-form-urlencoded", }), body: expect.stringContaining("grant_type=refresh_token"), }), ); // Should return updated props with new tokens expect(result).toBeDefined(); expect(result?.newProps).toMatchObject({ accessToken: "new-access-token", refreshToken: "new-refresh-token", accessTokenExpiresAt: expect.any(Number), }); expect(result?.accessTokenTTL).toBe(3600); }); it("should refresh token when no cached expiry exists", async () => { const options: TokenExchangeCallbackOptions = { grantType: "refresh_token", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: { id: "user-id", name: "Test User", accessToken: "old-access-token", refreshToken: "old-refresh-token", // No accessTokenExpiresAt } as WorkerProps, }; // Mock successful refresh response mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 3600, expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), token_type: "bearer", user: { id: "user-id", name: "Test User", email: "test@example.com", }, scope: "org:read project:read", }), }); const result = await tokenExchangeCallback(options, mockEnv); // Should call upstream API expect(mockFetch).toHaveBeenCalled(); // Should return updated props expect(result?.newProps).toMatchObject({ accessToken: "new-access-token", refreshToken: "new-refresh-token", accessTokenExpiresAt: expect.any(Number), }); }); it("should throw error when upstream refresh fails", async () => { const options: TokenExchangeCallbackOptions = { grantType: "refresh_token", clientId: "test-client-id", userId: "test-user-id", scope: ["org:read", "project:read"], props: { id: "user-id", name: "Test User", accessToken: "old-access-token", refreshToken: "invalid-refresh-token", } as WorkerProps, }; // Mock failed refresh response mockFetch.mockResolvedValueOnce({ ok: false, text: async () => "Invalid refresh token", }); await expect(tokenExchangeCallback(options, mockEnv)).rejects.toThrow( "Failed to refresh upstream token in OAuth provider", ); }); }); describe("refreshAccessToken", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should successfully refresh access token", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 3600, expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), token_type: "bearer", user: { id: "user-id", name: "Test User", email: "test@example.com", }, scope: "org:read project:read", }), }); const [result, error] = await refreshAccessToken({ client_id: "test-client", client_secret: "test-secret", refresh_token: "valid-refresh-token", upstream_url: "https://sentry.io/oauth/token/", }); expect(error).toBeNull(); expect(result).toMatchObject({ access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 3600, }); }); it("should return error when refresh token is missing", async () => { const [result, error] = await refreshAccessToken({ client_id: "test-client", client_secret: "test-secret", refresh_token: undefined, upstream_url: "https://sentry.io/oauth/token/", }); expect(result).toBeNull(); expect(error).toBeDefined(); expect(error?.status).toBe(400); const text = await error?.text(); expect(text).toBe("Invalid request: missing refresh token"); }); it("should return error when upstream returns non-OK status", async () => { mockFetch.mockResolvedValueOnce({ ok: false, text: async () => "Invalid token", }); const [result, error] = await refreshAccessToken({ client_id: "test-client", client_secret: "test-secret", refresh_token: "invalid-token", upstream_url: "https://sentry.io/oauth/token/", }); expect(result).toBeNull(); expect(error).toBeDefined(); expect(error?.status).toBe(400); const text = await error?.text(); expect(text).toContain("issue refreshing your access token"); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/getsentry/sentry-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server