Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/plugins/apps/src/vite/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +42,10 @@ const mockAuth: AuthOptionsWithDefaults = {
site: 'datadoghq.com',
};

const mockOauthOnlyAuth: AuthOptionsWithDefaults = {
site: 'datadoghq.com',
};

const mockLog = getMockLogger();

/**
Expand Down Expand Up @@ -121,6 +129,11 @@ function mockBuildWithParsedBackend(code = '// code') {
}

describe('Dev Server Middleware', () => {
beforeEach(() => {
jest.clearAllMocks();
mockViteBuild.mockReset();
});

afterEach(() => {
nock.cleanAll();
});
Expand All @@ -130,6 +143,7 @@ describe('Dev Server Middleware', () => {
mockViteBuild,
() => mockFunctions,
mockAuth,
'apiKey',
'/project',
mockLog,
);
Expand Down Expand Up @@ -217,6 +231,7 @@ describe('Dev Server Middleware', () => {
mockViteBuild,
() => mockFunctions,
mockAuth,
'apiKey',
'/project',
mockLog,
);
Expand Down Expand Up @@ -292,6 +307,7 @@ describe('Dev Server Middleware', () => {
mockViteBuild,
() => mockFunctions,
mockAuth,
'apiKey',
'/project',
mockLog,
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -474,6 +558,7 @@ describe('Dev Server Middleware', () => {
mockViteBuild,
() => functionsWithAllowlist,
mockAuth,
'apiKey',
'/project',
mockLog,
);
Expand Down Expand Up @@ -635,6 +720,7 @@ describe('Dev Server Middleware', () => {
mockViteBuild,
() => currentFunctions,
mockAuth,
'apiKey',
'/project',
mockLog,
);
Expand Down
70 changes: 41 additions & 29 deletions packages/plugins/apps/src/vite/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,7 +29,9 @@ type BundleFn = (func: BackendFunction) => Promise<BundleResult>;

const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:';

type AuthConfig = Required<AuthOptionsWithDefaults>;
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: <value> }`.
Expand Down Expand Up @@ -130,10 +134,11 @@ async function executeScriptViaDatadog(
scriptBody: string,
func: BackendFunction,
args: unknown[],
auth: AuthConfig,
site: Site,
doAuthenticatedRequest: DoAuthenticatedRequest,
log: Logger,
): Promise<BackendOutputs> {
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}`);
Expand Down Expand Up @@ -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: () => ({
Expand All @@ -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 {
Expand All @@ -192,10 +196,11 @@ interface PollResult {

async function pollQueryExecution(
receiptId: string,
auth: AuthConfig,
site: Site,
doAuthenticatedRequest: DoAuthenticatedRequest,
log: Logger,
): Promise<BackendOutputs> {
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;

/*
Expand All @@ -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<PollResult>({
const result = await doAuthenticatedRequest<PollResult>({
url: endpoint,
auth,
type: 'json',
});

Expand Down Expand Up @@ -314,7 +318,8 @@ async function handleExecuteAction(
res: ServerResponse,
functionsByName: Map<string, BackendFunction>,
bundle: BundleFn,
auth: AuthConfig,
site: Site,
doAuthenticatedRequest: DoAuthenticatedRequest,
log: Logger,
): Promise<void> {
try {
Expand All @@ -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');
Expand Down Expand Up @@ -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 {
Expand All @@ -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}`,
);
}

Expand All @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
},
};
Expand Down
Loading