0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
clearOAuthTokenCache,
|
|
PromptfooOAuthClientProvider,
|
|
} from '../../../src/providers/mcp/authProvider';
|
|
|
|
// Mock the util module to avoid actual HTTP requests
|
|
vi.mock('../../../src/providers/mcp/util', () => ({
|
|
getOAuthToken: vi.fn().mockResolvedValue('mock-access-token'),
|
|
}));
|
|
|
|
describe('PromptfooOAuthClientProvider', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clearOAuthTokenCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearOAuthTokenCache();
|
|
});
|
|
|
|
describe('client_credentials grant', () => {
|
|
it('should create provider with correct metadata', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
expect(provider.clientMetadata.grant_types).toContain('client_credentials');
|
|
expect(provider.redirectUrl).toBeUndefined();
|
|
|
|
const clientInfo = provider.clientInformation();
|
|
expect(clientInfo.client_id).toBe('test-client');
|
|
expect(clientInfo.client_secret).toBe('test-secret');
|
|
});
|
|
|
|
it('should prepare token request with correct grant type', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
scopes: ['read', 'write'],
|
|
});
|
|
|
|
const params = provider.prepareTokenRequest();
|
|
|
|
expect(params.get('grant_type')).toBe('client_credentials');
|
|
expect(params.get('scope')).toBe('read write');
|
|
expect(params.has('username')).toBe(false);
|
|
expect(params.has('password')).toBe(false);
|
|
});
|
|
|
|
it('should use scope parameter when provided', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
scopes: ['read'], // config scope
|
|
});
|
|
|
|
// Scope parameter should override config scope
|
|
const params = provider.prepareTokenRequest('custom-scope');
|
|
|
|
expect(params.get('scope')).toBe('custom-scope');
|
|
});
|
|
});
|
|
|
|
describe('password grant', () => {
|
|
it('should prepare token request with username and password', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'password',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
username: 'user@example.com',
|
|
password: 'secret123',
|
|
});
|
|
|
|
const params = provider.prepareTokenRequest();
|
|
|
|
expect(params.get('grant_type')).toBe('password');
|
|
expect(params.get('username')).toBe('user@example.com');
|
|
expect(params.get('password')).toBe('secret123');
|
|
});
|
|
|
|
it('should include optional clientId and clientSecret', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'password',
|
|
clientId: 'optional-client',
|
|
clientSecret: 'optional-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
username: 'user@example.com',
|
|
password: 'secret123',
|
|
});
|
|
|
|
const clientInfo = provider.clientInformation();
|
|
expect(clientInfo.client_id).toBe('optional-client');
|
|
expect(clientInfo.client_secret).toBe('optional-secret');
|
|
});
|
|
});
|
|
|
|
describe('token caching', () => {
|
|
it('should save and retrieve tokens', async () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
const tokens = {
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
};
|
|
|
|
provider.saveTokens(tokens);
|
|
|
|
expect(await provider.tokens()).toEqual(tokens);
|
|
});
|
|
|
|
it('should share tokens between provider instances with same config', async () => {
|
|
const config = {
|
|
type: 'oauth' as const,
|
|
grantType: 'client_credentials' as const,
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
};
|
|
|
|
const provider1 = new PromptfooOAuthClientProvider(config);
|
|
const provider2 = new PromptfooOAuthClientProvider(config);
|
|
|
|
const tokens = {
|
|
access_token: 'shared-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
};
|
|
|
|
provider1.saveTokens(tokens);
|
|
|
|
// Second provider should see the cached tokens
|
|
expect(await provider2.tokens()).toEqual(tokens);
|
|
});
|
|
|
|
it('should fetch fresh tokens when cache is empty', async () => {
|
|
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
|
|
vi.mocked(getOAuthToken).mockResolvedValueOnce('fresh-token');
|
|
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'client-1',
|
|
clientSecret: 'secret-1',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
const tokens = await provider.tokens();
|
|
|
|
expect(tokens).toBeDefined();
|
|
expect(tokens?.access_token).toBe('fresh-token');
|
|
expect(getOAuthToken).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should invalidate tokens when requested', async () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
provider.saveTokens({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
});
|
|
|
|
expect(await provider.tokens()).toBeDefined();
|
|
|
|
provider.invalidateCredentials('tokens');
|
|
|
|
// After invalidation, it will try to fetch fresh tokens
|
|
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
|
|
vi.mocked(getOAuthToken).mockResolvedValueOnce('new-token-after-invalidation');
|
|
|
|
const newTokens = await provider.tokens();
|
|
expect(newTokens?.access_token).toBe('new-token-after-invalidation');
|
|
});
|
|
|
|
it('should invalidate all credentials when scope is "all"', async () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
provider.saveTokens({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
});
|
|
|
|
provider.invalidateCredentials('all');
|
|
|
|
// After invalidation, it will try to fetch fresh tokens
|
|
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
|
|
vi.mocked(getOAuthToken).mockResolvedValueOnce('new-token-after-all-invalidation');
|
|
|
|
const newTokens = await provider.tokens();
|
|
expect(newTokens?.access_token).toBe('new-token-after-all-invalidation');
|
|
});
|
|
});
|
|
|
|
describe('client information', () => {
|
|
it('should save and retrieve client information', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'original-client',
|
|
clientSecret: 'original-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
const newClientInfo = {
|
|
client_id: 'new-client',
|
|
client_secret: 'new-secret',
|
|
};
|
|
|
|
provider.saveClientInformation(newClientInfo);
|
|
|
|
expect(provider.clientInformation()).toEqual(newClientInfo);
|
|
});
|
|
});
|
|
|
|
describe('non-interactive flow methods', () => {
|
|
it('should throw on redirectToAuthorization', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
expect(() => provider.redirectToAuthorization(new URL('https://example.com'))).toThrow(
|
|
'not supported',
|
|
);
|
|
});
|
|
|
|
it('should throw on codeVerifier', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
expect(() => provider.codeVerifier()).toThrow('not supported');
|
|
});
|
|
|
|
it('should not throw on saveCodeVerifier (no-op)', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
// Should not throw
|
|
expect(() => provider.saveCodeVerifier('some-verifier')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('client metadata', () => {
|
|
it('should have correct token_endpoint_auth_method when client secret is present', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
expect(provider.clientMetadata.token_endpoint_auth_method).toBe('client_secret_basic');
|
|
});
|
|
|
|
it('should have token_endpoint_auth_method as "none" when no client secret', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'password',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
username: 'user',
|
|
password: 'pass',
|
|
});
|
|
|
|
expect(provider.clientMetadata.token_endpoint_auth_method).toBe('none');
|
|
});
|
|
|
|
it('should have empty redirect_uris for non-interactive flows', () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
expect(provider.clientMetadata.redirect_uris).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('clearOAuthTokenCache', () => {
|
|
beforeEach(() => {
|
|
clearOAuthTokenCache();
|
|
});
|
|
|
|
it('should clear all cached tokens', async () => {
|
|
const provider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
provider.saveTokens({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
});
|
|
|
|
expect(await provider.tokens()).toBeDefined();
|
|
|
|
clearOAuthTokenCache();
|
|
|
|
// Create a new provider with same config - should fetch fresh tokens
|
|
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
|
|
vi.mocked(getOAuthToken).mockResolvedValueOnce('fresh-token-after-clear');
|
|
|
|
const newProvider = new PromptfooOAuthClientProvider({
|
|
type: 'oauth',
|
|
grantType: 'client_credentials',
|
|
clientId: 'test-client',
|
|
clientSecret: 'test-secret',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
});
|
|
|
|
const tokens = await newProvider.tokens();
|
|
expect(tokens?.access_token).toBe('fresh-token-after-clear');
|
|
});
|
|
});
|