Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

282 lines
9.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockFetchWithProxy = vi.hoisted(() => vi.fn());
vi.mock('../../src/util/fetch/index', () => ({
fetchWithProxy: mockFetchWithProxy,
}));
import { fetchOAuthToken, TOKEN_REFRESH_BUFFER_MS } from '../../src/util/oauth';
describe('oauth utils', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchWithProxy.mockReset();
});
describe('TOKEN_REFRESH_BUFFER_MS', () => {
it('should be 60 seconds', () => {
expect(TOKEN_REFRESH_BUFFER_MS).toBe(60000);
});
});
describe('fetchOAuthToken', () => {
it('should fetch token with client_credentials grant', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'test-token',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
});
expect(result.accessToken).toBe('test-token');
expect(result.expiresAt).toBeGreaterThan(Date.now());
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('grant_type=client_credentials'),
});
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).toContain('client_id=test-client');
expect(callBody).toContain('client_secret=test-secret');
});
it('should fetch token with password grant', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'password-token',
expires_in: 7200,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'password',
clientId: 'test-client',
clientSecret: 'test-secret',
username: 'testuser',
password: 'testpass',
});
expect(result.accessToken).toBe('password-token');
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).toContain('grant_type=password');
expect(callBody).toContain('username=testuser');
expect(callBody).toContain('password=testpass');
});
it('should include scopes when provided', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'scoped-token',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
clientId: 'test-client',
scopes: ['read', 'write', 'admin'],
});
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).toContain('scope=read+write+admin');
});
it('should work without optional clientId and clientSecret', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'minimal-token',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
});
expect(result.accessToken).toBe('minimal-token');
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).not.toContain('client_id');
expect(callBody).not.toContain('client_secret');
});
it('should default expires_in to 3600 seconds when not provided', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'no-expiry-token',
// No expires_in field
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
const beforeFetch = Date.now();
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
});
// Should default to 3600 seconds (1 hour)
const expectedMinExpiry = beforeFetch + 3600 * 1000;
const expectedMaxExpiry = Date.now() + 3600 * 1000;
expect(result.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(result.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
});
it('should use provided expires_in value', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'custom-expiry-token',
expires_in: 1800, // 30 minutes
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
const beforeFetch = Date.now();
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
});
// Should use 1800 seconds (30 minutes)
const expectedMinExpiry = beforeFetch + 1800 * 1000;
const expectedMaxExpiry = Date.now() + 1800 * 1000;
expect(result.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(result.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
});
it('should throw error when response is not ok', async () => {
const mockResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: vi.fn().mockResolvedValue('{"error":"invalid_client"}'),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
await expect(
fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
clientId: 'bad-client',
}),
).rejects.toThrow('OAuth token request failed with status 401 Unauthorized');
});
it('should throw error when access_token is missing in response', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
// No access_token field
token_type: 'Bearer',
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
await expect(
fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
}),
).rejects.toThrow('OAuth token response missing access_token');
});
it('should handle password grant without username/password gracefully', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'token-without-creds',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
// Password grant without username/password - the function should still make the request
// (server will likely reject it, but that's not our concern here)
const result = await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'password',
});
expect(result.accessToken).toBe('token-without-creds');
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).toContain('grant_type=password');
// Should not have username= or password= fields (distinct from grant_type=password)
expect(callBody).not.toContain('username=');
expect(callBody).not.toContain('&password=');
});
it('should not include empty scopes array', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'no-scope-token',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'client_credentials',
scopes: [],
});
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
expect(callBody).not.toContain('scope');
});
it('should URL-encode special characters in credentials', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
access_token: 'encoded-token',
expires_in: 3600,
}),
};
mockFetchWithProxy.mockResolvedValue(mockResponse);
await fetchOAuthToken({
tokenUrl: 'https://auth.example.com/token',
grantType: 'password',
clientId: 'client+id',
clientSecret: 'secret&value',
username: 'user@example.com',
password: 'pass=word!',
});
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
// URLSearchParams should encode special characters
expect(callBody).toContain('client_id=client%2Bid');
expect(callBody).toContain('client_secret=secret%26value');
expect(callBody).toContain('username=user%40example.com');
expect(callBody).toContain('password=pass%3Dword%21');
});
});
});