diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index efe8ad944..0563e0aa4 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -13,6 +13,10 @@ import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; +jest.mock('@dd/core/helpers/oauth', () => ({ + resolveOAuthToken: jest.fn().mockResolvedValue({ accessToken: 'test-oauth-token' }), +})); + const mockViteBuild = jest.fn(); const DD_API_ORIGIN = 'https://api.datadoghq.com'; @@ -38,6 +42,10 @@ const mockAuth: AuthOptionsWithDefaults = { site: 'datadoghq.com', }; +const mockOauthOnlyAuth: AuthOptionsWithDefaults = { + site: 'datadoghq.com', +}; + const mockLog = getMockLogger(); /** @@ -121,6 +129,11 @@ function mockBuildWithParsedBackend(code = '// code') { } describe('Dev Server Middleware', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockViteBuild.mockReset(); + }); + afterEach(() => { nock.cleanAll(); }); @@ -130,6 +143,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, () => mockFunctions, mockAuth, + 'apiKey', '/project', mockLog, ); @@ -217,6 +231,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, () => mockFunctions, mockAuth, + 'apiKey', '/project', mockLog, ); @@ -292,6 +307,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, () => mockFunctions, mockAuth, + 'apiKey', '/project', mockLog, ); @@ -409,6 +425,74 @@ describe('Dev Server Middleware', () => { expect(capturedBody?.data.attributes.template_params).toEqual({}); }); + test('Should call Datadog API with OAuth when configured without API/App keys', async () => { + mockBuildWithParsedBackend(); + + const oauthMiddleware = createDevServerMiddleware( + mockViteBuild, + () => mockFunctions, + mockOauthOnlyAuth, + 'oauth', + '/project', + mockLog, + ); + + const apiScope = nock(DD_API_ORIGIN, { + reqheaders: { + Authorization: 'Bearer test-oauth-token', + }, + badheaders: ['DD-API-KEY', 'DD-APPLICATION-KEY'], + }) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-oauth' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-oauth') + .reply(200, { + data: { attributes: { done: true, outputs: { data: { ok: true } } } }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + oauthMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { ok: true } }); + expect(apiScope.isDone()).toBe(true); + }); + + test('Should return 403 with auth guidance when default API-key auth is missing keys', async () => { + const noKeyMiddleware = createDevServerMiddleware( + mockViteBuild, + () => mockFunctions, + mockOauthOnlyAuth, + 'apiKey', + '/project', + mockLog, + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + noKeyMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.getBody()); + expect(body.error).toContain('DD_APPS_AUTH_METHOD=oauth'); + expect(body.error).toContain('DD_API_KEY'); + expect(body.error).toContain('DD_APP_KEY'); + expect(mockViteBuild).not.toHaveBeenCalled(); + }); + test('Should round-trip args containing single quotes via inputs.context', async () => { // Regression: textual substitution into a single-quoted JS string // literal broke when args contained `'`. Args must now appear @@ -474,6 +558,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, () => functionsWithAllowlist, mockAuth, + 'apiKey', '/project', mockLog, ); @@ -635,6 +720,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, () => currentFunctions, mockAuth, + 'apiKey', '/project', mockLog, ); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index de8304b43..6722f0284 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -4,16 +4,18 @@ /* eslint-disable no-await-in-loop */ -import { doRequest } from '@dd/core/helpers/request'; -import type { AuthOptionsWithDefaults, Logger } from '@dd/core/types'; +import type { AuthOptionsWithDefaults, Logger, Site } from '@dd/core/types'; import { randomUUID } from 'crypto'; import type { IncomingMessage, ServerResponse } from 'http'; import type { build } from 'vite'; +import { getAuthenticatedRequest } from '../auth'; +import type { DoAuthenticatedRequest } from '../auth'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; +import type { AuthMethod } from '../types'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; @@ -27,7 +29,9 @@ type BundleFn = (func: BackendFunction) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; -type AuthConfig = Required; +const AUTH_GUIDANCE = + 'Set apps.authOverrides.method: "oauth" or DD_APPS_AUTH_METHOD=oauth to use OAuth, ' + + 'or set DD_API_KEY and DD_APP_KEY to use API/App key auth.'; /** Shape of the `outputs` field in a Datadog app-builder query response — * the API wraps a JS action's return value as `{ data: }`. @@ -130,10 +134,11 @@ async function executeScriptViaDatadog( scriptBody: string, func: BackendFunction, args: unknown[], - auth: AuthConfig, + site: Site, + doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, ): Promise { - const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/preview-async`; + const endpoint = `https://api.${site}/api/v2/app-builder/queries/preview-async`; const displayName = formatRef(func); log.debug(`Calling Datadog API: ${endpoint}`); @@ -163,9 +168,8 @@ async function executeScriptViaDatadog( }, }); - const initialResult = await doRequest<{ data?: { id?: string } }>({ + const initialResult = await doAuthenticatedRequest<{ data?: { id?: string } }>({ url: endpoint, - auth, method: 'POST', type: 'json', getData: () => ({ @@ -182,7 +186,7 @@ async function executeScriptViaDatadog( log.debug(`Query execution started with receipt: ${receiptId}`); - return pollQueryExecution(receiptId, auth, log); + return pollQueryExecution(receiptId, site, doAuthenticatedRequest, log); } interface PollResult { @@ -192,10 +196,11 @@ interface PollResult { async function pollQueryExecution( receiptId: string, - auth: AuthConfig, + site: Site, + doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, ): Promise { - const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/execution-long-polling/${receiptId}`; + const endpoint = `https://api.${site}/api/v2/app-builder/queries/execution-long-polling/${receiptId}`; const maxRetries = 10; /* @@ -214,9 +219,8 @@ async function pollQueryExecution( for (let attempt = 0; attempt < maxRetries; attempt++) { log.debug(`Long-poll attempt ${attempt + 1}/${maxRetries}...`); - const result = await doRequest({ + const result = await doAuthenticatedRequest({ url: endpoint, - auth, type: 'json', }); @@ -314,7 +318,8 @@ async function handleExecuteAction( res: ServerResponse, functionsByName: Map, bundle: BundleFn, - auth: AuthConfig, + site: Site, + doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, ): Promise { try { @@ -323,7 +328,14 @@ async function handleExecuteAction( log.debug(`Executing action: ${displayName} with args`); - const result = await executeScriptViaDatadog(code, func, args, auth, log); + const result = await executeScriptViaDatadog( + code, + func, + args, + site, + doAuthenticatedRequest, + log, + ); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); @@ -355,6 +367,7 @@ export function createDevServerMiddleware( viteBuild: typeof build, getBackendFunctions: () => BackendFunction[], auth: AuthOptionsWithDefaults, + authMethod: AuthMethod, projectRoot: string, log: Logger, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { @@ -368,16 +381,11 @@ export function createDevServerMiddleware( ); } - // Narrow auth once — executeAction needs all three fields present. - const fullAuth: AuthConfig | undefined = - auth.apiKey && auth.appKey - ? { apiKey: auth.apiKey, appKey: auth.appKey, site: auth.site } - : undefined; + const doAuthenticatedRequest = getAuthenticatedRequest(authMethod, auth, log); - if (!fullAuth) { + if (!doAuthenticatedRequest) { log.warn( - 'Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ' + - 'Set DD_API_KEY and DD_APP_KEY to enable remote execution.', + `Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ${AUTH_GUIDANCE}`, ); } @@ -394,15 +402,19 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { - if (!fullAuth) { - sendError( - res, - 403, - 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY to enable remote execution.', - ); + if (!doAuthenticatedRequest) { + sendError(res, 403, `Auth credentials not configured. ${AUTH_GUIDANCE}`); return; } - handleExecuteAction(req, res, functionsByName, bundle, fullAuth, log).catch(() => { + handleExecuteAction( + req, + res, + functionsByName, + bundle, + auth.site, + doAuthenticatedRequest, + log, + ).catch(() => { sendError(res, 500, 'Unexpected error'); }); } else { diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index fbf2b9626..c65c3dade 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -170,7 +170,14 @@ export const getVitePlugin = ({ }, configureServer(server) { server.middlewares.use( - createDevServerMiddleware(bundler.build, getBackendFunctions, auth, buildRoot, log), + createDevServerMiddleware( + bundler.build, + getBackendFunctions, + auth, + options.authOverrides.method, + buildRoot, + log, + ), ); }, };