diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js index 7ea441b69..2b859e335 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js @@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react'; import get from 'lodash/get'; import { IconCaretDown } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; +import StatusBadge from 'ui/StatusBadge/index'; import { useDispatch } from 'react-redux'; import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections'; import { humanizeRequestAuthMode } from 'utils/collections'; @@ -66,6 +67,17 @@ const AuthMode = ({ collection }) => { label: 'API Key', onClick: () => onModeChange('apikey') }, + { + id: 'akamai-edgegrid', + label: ( + + Akamai EdgeGrid + Beta + + ), + ariaLabel: 'Akamai EdgeGrid (Beta)', + onClick: () => onModeChange('akamai-edgegrid') + }, { id: 'none', label: 'No Auth', diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/StyledWrapper.js new file mode 100644 index 000000000..02ac95deb --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/StyledWrapper.js @@ -0,0 +1,97 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + label { + display: flex; + align-items: center; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.subtext1}; + margin-bottom: 0.5rem; + } + + .single-line-editor-wrapper { + display: flex; + align-items: center; + max-width: 400px; + margin-bottom: 0.5rem; + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + + &:focus-within { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + } + } + + .advanced-settings-header { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + margin: 1rem 0 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.subtext1}; + user-select: none; + + .advanced-settings-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem; + background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)}; + + svg { + color: ${(props) => props.theme.primary.text}; + } + } + } + + .advanced-settings-hint { + margin: -0.25rem 0 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: rgb(107 114 128); + } + + .field-info { + position: relative; + display: inline-flex; + align-items: center; + margin-left: 6px; + cursor: pointer; + + svg { + color: rgb(107 114 128); + } + + .field-tooltip { + position: absolute; + left: 0; + bottom: 100%; + z-index: 10; + width: max-content; + max-width: 15rem; + margin-bottom: 0.25rem; + padding: 0.5rem; + border-radius: 0.375rem; + background-color: #374151; + color: #fff; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 400; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + &:hover .field-tooltip { + opacity: 1; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/index.tsx b/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/index.tsx new file mode 100644 index 000000000..2d0dfae4a --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/EdgeGridAuth/index.tsx @@ -0,0 +1,165 @@ +import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons'; +import get from 'lodash/get'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import SensitiveFieldWarning from 'components/SensitiveFieldWarning'; +import SingleLineEditor from 'components/SingleLineEditor'; +import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField'; +import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections'; +import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import StyledWrapper from './StyledWrapper'; + +interface AkamaiEdgeGridAuthValues { + accessToken?: string; + clientToken?: string; + clientSecret?: string; + nonce?: string; + timestamp?: string; + baseURL?: string; + headersToSign?: string; + maxBodySize?: number | null; +} + +type EdgeGridField = keyof AkamaiEdgeGridAuthValues; + +const toMaxBodySize = (value: string): number | null => { + if (value === '' || value == null) return null; + const num = Number(value); + return Number.isNaN(num) ? null : num; +}; + +interface AkamaiEdgeGridAuthProps { + collection: any; +} + +const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [ + { key: 'accessToken', label: 'Access Token' }, + { key: 'clientToken', label: 'Client Token' }, + { key: 'clientSecret', label: 'Client Secret', isSecret: true }, + { key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' }, + { + key: 'nonce', + label: 'Nonce', + tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.' + }, + { + key: 'timestamp', + label: 'Timestamp', + tooltip: + 'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.' + }, + { + key: 'headersToSign', + label: 'Headers to Sign', + tooltip: 'Comma-separated list of headers to include in the signature.' + }, + { + key: 'maxBodySize', + label: 'Max Body Size', + tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.' + } +]; + +type EdgeGridFieldConfig = (typeof FIELDS)[number]; + +// Fields shown up front vs. those grouped under the "Advanced Settings" section +const BASIC_FIELDS = FIELDS.slice(0, 3); +const ADVANCED_FIELDS = FIELDS.slice(3); + +const EdgeGridAuth: React.FC = ({ collection }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + + const edgeGridAuth: AkamaiEdgeGridAuthValues = + (collection.draft?.root + ? get(collection, 'draft.root.request.auth.akamaiEdgegrid') + : get(collection, 'root.request.auth.akamaiEdgegrid')) || {}; + const { isSensitive } = useDetectSensitiveField(collection); + const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive( + edgeGridAuth?.clientSecret + ); + + const handleSave = () => dispatch(saveCollectionRoot(collection.uid)); + + const handleFieldChange = (field: EdgeGridField, value: string) => { + const content: AkamaiEdgeGridAuthValues = { + accessToken: edgeGridAuth.accessToken || '', + clientToken: edgeGridAuth.clientToken || '', + clientSecret: edgeGridAuth.clientSecret || '', + nonce: edgeGridAuth.nonce || '', + timestamp: edgeGridAuth.timestamp || '', + baseURL: edgeGridAuth.baseURL || '', + headersToSign: edgeGridAuth.headersToSign || '', + maxBodySize: edgeGridAuth.maxBodySize ?? null + }; + + if (field === 'maxBodySize') { + content.maxBodySize = toMaxBodySize(value); + } else { + (content as Record)[field] = value || ''; + } + + dispatch( + updateCollectionAuth({ + mode: 'akamai-edgegrid', + collectionUid: collection.uid, + content + }) + ); + }; + + const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => { + const showWarning = isSecret && showClientSecretWarning; + const rawValue = edgeGridAuth[key]; + const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue); + return ( + + + {label} + {tooltip && ( + + + {tooltip} + + )} + + + handleFieldChange(key, val)} + collection={collection} + isSecret={isSecret} + isCompact + /> + {showWarning && ( + + )} + + + ); + }; + + return ( + + {BASIC_FIELDS.map(renderField)} + + + + + + Advanced Settings + + + <> + {ADVANCED_FIELDS.map(renderField)} + > + + + ); +}; + +export default EdgeGridAuth; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/index.js index 228d29a25..129064ff3 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/index.js @@ -8,6 +8,7 @@ import BasicAuth from './BasicAuth'; import DigestAuth from './DigestAuth'; import WsseAuth from './WsseAuth'; import ApiKeyAuth from './ApiKeyAuth/'; +import EdgeGridAuth from './EdgeGridAuth'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import OAuth2 from './OAuth2'; @@ -50,6 +51,9 @@ const Auth = ({ collection }) => { case 'apikey': { return ; } + case 'akamai-edgegrid': { + return ; + } } }; diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js index 35687bf93..6c78aa87c 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -1,26 +1,27 @@ -import React, { useMemo } from 'react'; -import get from 'lodash/get'; -import StyledWrapper from './StyledWrapper'; -import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; -import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index'; -import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections'; -import { useDispatch } from 'react-redux'; -import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; -import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; -import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index'; -import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index'; -import AuthMode from '../AuthMode'; +import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth'; +import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth'; import BasicAuth from 'components/RequestPane/Auth/BasicAuth'; import BearerAuth from 'components/RequestPane/Auth/BearerAuth'; import DigestAuth from 'components/RequestPane/Auth/DigestAuth'; +import EdgeGridAuth from 'components/RequestPane/Auth/EdgeGridAuth'; import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth'; import OAuth1 from 'components/RequestPane/Auth/OAuth1'; +import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index'; +import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; +import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index'; +import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index'; +import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; import WsseAuth from 'components/RequestPane/Auth/WsseAuth'; -import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth'; -import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth'; -import { humanizeRequestAuthMode } from 'utils/collections/index'; +import get from 'lodash/get'; +import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections'; +import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; +import React, { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import Button from 'ui/Button'; import { getEffectiveAuthSource } from 'utils/auth'; +import { humanizeRequestAuthMode } from 'utils/collections/index'; +import AuthMode from '../AuthMode'; +import StyledWrapper from './StyledWrapper'; const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => { const dispatch = useDispatch(); @@ -182,6 +183,19 @@ const Auth = ({ collection, folder }) => { > ); } + case 'akamai-edgegrid': { + return ( + <> + handleSave()} + /> + > + ); + } case 'none': { return null; } diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js index 29044861a..01a4ed6fc 100644 --- a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js @@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react'; import get from 'lodash/get'; import { IconCaretDown } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; +import StatusBadge from 'ui/StatusBadge/index'; import { useDispatch } from 'react-redux'; import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections'; import { humanizeRequestAuthMode } from 'utils/collections'; @@ -67,6 +68,17 @@ const AuthMode = ({ collection, folder }) => { label: 'API Key', onClick: () => onModeChange('apikey') }, + { + id: 'akamai-edgegrid', + label: ( + + Akamai EdgeGrid + Beta + + ), + ariaLabel: 'Akamai EdgeGrid (Beta)', + onClick: () => onModeChange('akamai-edgegrid') + }, { id: 'inherit', label: 'Inherit', diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js index 18653336b..1a5801746 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js @@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react'; import get from 'lodash/get'; import { IconCaretDown } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; +import StatusBadge from 'ui/StatusBadge/index'; import { useDispatch } from 'react-redux'; import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections'; import { humanizeRequestAuthMode } from 'utils/collections'; @@ -67,6 +68,17 @@ const AuthMode = ({ item, collection }) => { label: 'API Key', onClick: () => onModeChange('apikey') }, + { + id: 'akamai-edgegrid', + label: ( + + Akamai EdgeGrid + Beta + + ), + ariaLabel: 'Akamai EdgeGrid (Beta)', + onClick: () => onModeChange('akamai-edgegrid') + }, { id: 'inherit', label: 'Inherit', diff --git a/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/StyledWrapper.js new file mode 100644 index 000000000..02ac95deb --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/StyledWrapper.js @@ -0,0 +1,97 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + label { + display: flex; + align-items: center; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.subtext1}; + margin-bottom: 0.5rem; + } + + .single-line-editor-wrapper { + display: flex; + align-items: center; + max-width: 400px; + margin-bottom: 0.5rem; + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + + &:focus-within { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + } + } + + .advanced-settings-header { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + margin: 1rem 0 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.subtext1}; + user-select: none; + + .advanced-settings-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem; + background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)}; + + svg { + color: ${(props) => props.theme.primary.text}; + } + } + } + + .advanced-settings-hint { + margin: -0.25rem 0 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: rgb(107 114 128); + } + + .field-info { + position: relative; + display: inline-flex; + align-items: center; + margin-left: 6px; + cursor: pointer; + + svg { + color: rgb(107 114 128); + } + + .field-tooltip { + position: absolute; + left: 0; + bottom: 100%; + z-index: 10; + width: max-content; + max-width: 15rem; + margin-bottom: 0.25rem; + padding: 0.5rem; + border-radius: 0.375rem; + background-color: #374151; + color: #fff; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 400; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + &:hover .field-tooltip { + opacity: 1; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/index.tsx b/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/index.tsx new file mode 100644 index 000000000..1d08bb509 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/EdgeGridAuth/index.tsx @@ -0,0 +1,174 @@ +import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons'; +import get from 'lodash/get'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import SensitiveFieldWarning from 'components/SensitiveFieldWarning'; +import SingleLineEditor from 'components/SingleLineEditor'; +import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField'; +import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import StyledWrapper from './StyledWrapper'; + +interface AkamaiEdgeGridAuthValues { + accessToken?: string; + clientToken?: string; + clientSecret?: string; + nonce?: string; + timestamp?: string; + baseURL?: string; + headersToSign?: string; + maxBodySize?: number | null; +} + +type EdgeGridField = keyof AkamaiEdgeGridAuthValues; + +// Coerce the Max Body Size editor string into the numeric value the model stores (empty/invalid -> null). +const toMaxBodySize = (value: string): number | null => { + if (value === '' || value == null) return null; + const num = Number(value); + return Number.isNaN(num) ? null : num; +}; + +interface AkamaiEdgeGridAuthProps { + item: any; + collection: any; + request: any; + updateAuth: (payload: any) => any; + save: () => void; +} + +const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [ + { key: 'accessToken', label: 'Access Token' }, + { key: 'clientToken', label: 'Client Token' }, + { key: 'clientSecret', label: 'Client Secret', isSecret: true }, + { key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' }, + { + key: 'nonce', + label: 'Nonce', + tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.' + }, + { + key: 'timestamp', + label: 'Timestamp', + tooltip: + 'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.' + }, + { + key: 'headersToSign', + label: 'Headers to Sign', + tooltip: 'Comma-separated list of headers to include in the signature.' + }, + { + key: 'maxBodySize', + label: 'Max Body Size', + tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.' + } +]; + +type EdgeGridFieldConfig = (typeof FIELDS)[number]; + +// Fields shown up front vs. those grouped under the "Advanced Settings" section +const BASIC_FIELDS = FIELDS.slice(0, 3); +const ADVANCED_FIELDS = FIELDS.slice(3); + +const EdgeGridAuth: React.FC = ({ item, collection, updateAuth, request, save }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + + const edgeGridAuth: AkamaiEdgeGridAuthValues = get(request, 'auth.akamaiEdgegrid') || {}; + const requestUrl = get(request, 'url', ''); + const { isSensitive } = useDetectSensitiveField(collection); + const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive( + edgeGridAuth?.clientSecret + ); + + const handleRun = () => dispatch(sendRequest(item, collection.uid)); + + const handleSave = () => { + save(); + }; + + const handleFieldChange = (field: EdgeGridField, value: string) => { + const content: AkamaiEdgeGridAuthValues = { + accessToken: edgeGridAuth.accessToken || '', + clientToken: edgeGridAuth.clientToken || '', + clientSecret: edgeGridAuth.clientSecret || '', + nonce: edgeGridAuth.nonce || '', + timestamp: edgeGridAuth.timestamp || '', + baseURL: edgeGridAuth.baseURL || '', + headersToSign: edgeGridAuth.headersToSign || '', + maxBodySize: edgeGridAuth.maxBodySize ?? null + }; + + if (field === 'maxBodySize') { + content.maxBodySize = toMaxBodySize(value); + } else { + (content as Record)[field] = value || ''; + } + + dispatch( + updateAuth({ + mode: 'akamai-edgegrid', + collectionUid: collection.uid, + itemUid: item.uid, + content + }) + ); + }; + + const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => { + const showWarning = isSecret && showClientSecretWarning; + const rawValue = key === 'baseURL' ? edgeGridAuth.baseURL || requestUrl : edgeGridAuth[key]; + const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue); + return ( + + + {label} + {tooltip && ( + + + {tooltip} + + )} + + + handleFieldChange(key, val)} + onRun={handleRun} + collection={collection} + item={item} + isSecret={isSecret} + isCompact + /> + {showWarning && ( + + )} + + + ); + }; + + return ( + + {BASIC_FIELDS.map(renderField)} + + + + + + Advanced Settings + + + <> + {ADVANCED_FIELDS.map(renderField)} + > + + + ); +}; + +export default EdgeGridAuth; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/index.js index 491934bbb..8dd56b741 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/index.js @@ -12,6 +12,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import ApiKeyAuth from './ApiKeyAuth'; +import EdgeGridAuth from './EdgeGridAuth'; import StyledWrapper from './StyledWrapper'; import { humanizeRequestAuthMode } from 'utils/collections'; import OAuth2 from './OAuth2/index'; @@ -68,6 +69,9 @@ const Auth = ({ item, collection }) => { case 'apikey': { return ; } + case 'akamai-edgegrid': { + return ; + } case 'inherit': { return ( <> diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 2dc35d5cc..fcfa08278 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1111,6 +1111,10 @@ export const collectionsSlice = createSlice({ item.draft.request.auth.mode = 'apikey'; item.draft.request.auth.apikey = action.payload.content; break; + case 'akamai-edgegrid': + item.draft.request.auth.mode = 'akamai-edgegrid'; + item.draft.request.auth.akamaiEdgegrid = action.payload.content; + break; } } } @@ -2233,6 +2237,9 @@ export const collectionsSlice = createSlice({ case 'apikey': set(collection, 'draft.root.request.auth.apikey', action.payload.content); break; + case 'akamai-edgegrid': + set(collection, 'draft.root.request.auth.akamaiEdgegrid', action.payload.content); + break; } } }, @@ -2565,6 +2572,9 @@ export const collectionsSlice = createSlice({ case 'apikey': set(folder, 'draft.request.auth.apikey', action.payload.content); break; + case 'akamai-edgegrid': + set(folder, 'draft.request.auth.akamaiEdgegrid', action.payload.content); + break; case 'awsv4': set(folder, 'draft.request.auth.awsv4', action.payload.content); break; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 0c10eb31b..fc72c1603 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -497,6 +497,18 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} password: get(si.request, 'auth.wsse.password', '') }; break; + case 'akamai-edgegrid': + di.request.auth.akamaiEdgegrid = { + accessToken: get(si.request, 'auth.akamaiEdgegrid.accessToken', ''), + clientToken: get(si.request, 'auth.akamaiEdgegrid.clientToken', ''), + clientSecret: get(si.request, 'auth.akamaiEdgegrid.clientSecret', ''), + nonce: get(si.request, 'auth.akamaiEdgegrid.nonce', ''), + timestamp: get(si.request, 'auth.akamaiEdgegrid.timestamp', ''), + baseURL: get(si.request, 'auth.akamaiEdgegrid.baseURL', ''), + headersToSign: get(si.request, 'auth.akamaiEdgegrid.headersToSign', ''), + maxBodySize: get(si.request, 'auth.akamaiEdgegrid.maxBodySize', null) + }; + break; default: break; } @@ -1014,6 +1026,10 @@ export const humanizeRequestAuthMode = (mode) => { label = 'API Key'; break; } + case 'akamai-edgegrid': { + label = 'Akamai EdgeGrid'; + break; + } } return label; diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 484e25b62..e132f3e09 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -94,6 +94,19 @@ const prepareRequest = async (item = {}, collection = {}) => { }; } + if (collectionAuth.mode === 'akamai-edgegrid') { + axiosRequest.edgeGridConfig = { + accessToken: get(collectionAuth, 'akamaiEdgegrid.accessToken'), + clientToken: get(collectionAuth, 'akamaiEdgegrid.clientToken'), + clientSecret: get(collectionAuth, 'akamaiEdgegrid.clientSecret'), + nonce: get(collectionAuth, 'akamaiEdgegrid.nonce'), + timestamp: get(collectionAuth, 'akamaiEdgegrid.timestamp'), + baseURL: get(collectionAuth, 'akamaiEdgegrid.baseURL'), + headersToSign: get(collectionAuth, 'akamaiEdgegrid.headersToSign'), + maxBodySize: get(collectionAuth, 'akamaiEdgegrid.maxBodySize') + }; + } + if (collectionAuth.mode === 'oauth2') { const grantType = get(collectionAuth, 'oauth2.grantType'); @@ -329,6 +342,19 @@ const prepareRequest = async (item = {}, collection = {}) => { } } } + + if (request.auth.mode === 'akamai-edgegrid') { + axiosRequest.edgeGridConfig = { + accessToken: get(request, 'auth.akamaiEdgegrid.accessToken'), + clientToken: get(request, 'auth.akamaiEdgegrid.clientToken'), + clientSecret: get(request, 'auth.akamaiEdgegrid.clientSecret'), + nonce: get(request, 'auth.akamaiEdgegrid.nonce'), + timestamp: get(request, 'auth.akamaiEdgegrid.timestamp'), + baseURL: get(request, 'auth.akamaiEdgegrid.baseURL'), + headersToSign: get(request, 'auth.akamaiEdgegrid.headersToSign'), + maxBodySize: get(request, 'auth.akamaiEdgegrid.maxBodySize') + }; + } } request.body = request.body || {}; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index f5525fe80..8914ac547 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -18,7 +18,7 @@ const { parseDataFromResponse } = require('../utils/common'); const { getCookieStringForUrl, saveCookies } = require('../utils/cookies'); const { createFormData } = require('../utils/form-data'); const { NtlmClient } = require('axios-ntlm'); -const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests'); +const { addDigestInterceptor, addEdgeGridInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests'); const { getCACertificates, transformProxyConfig } = require('@usebruno/requests'); const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2'); const tokenStore = require('../store/tokenStore'); @@ -692,6 +692,11 @@ const runSingleRequest = async function ( delete request.digestConfig; } + if (request.edgeGridConfig) { + addEdgeGridInterceptor(axiosInstance, request); + delete request.edgeGridConfig; + } + /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); diff --git a/packages/bruno-converters/src/postman/bruno-to-postman.js b/packages/bruno-converters/src/postman/bruno-to-postman.js index c543efb46..d99d9fd11 100644 --- a/packages/bruno-converters/src/postman/bruno-to-postman.js +++ b/packages/bruno-converters/src/postman/bruno-to-postman.js @@ -420,6 +420,21 @@ export const brunoToPostman = (collection) => { ] }; } + case 'akamai-edgegrid': { + return { + type: 'edgegrid', + edgegrid: [ + { key: 'accessToken', value: itemAuth.akamaiEdgegrid?.accessToken || '', type: 'string' }, + { key: 'clientToken', value: itemAuth.akamaiEdgegrid?.clientToken || '', type: 'string' }, + { key: 'clientSecret', value: itemAuth.akamaiEdgegrid?.clientSecret || '', type: 'string' }, + { key: 'baseURL', value: itemAuth.akamaiEdgegrid?.baseURL || '', type: 'string' }, + { key: 'nonce', value: itemAuth.akamaiEdgegrid?.nonce || '', type: 'string' }, + { key: 'timestamp', value: itemAuth.akamaiEdgegrid?.timestamp || '', type: 'string' }, + { key: 'headersToSign', value: itemAuth.akamaiEdgegrid?.headersToSign || '', type: 'string' }, + { key: 'maxBodySize', value: itemAuth.akamaiEdgegrid?.maxBodySize ?? '', type: 'string' } + ] + }; + } default: { return { type: 'noauth' diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 8027c181b..783f4f451 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -1,14 +1,14 @@ -import get from 'lodash/get'; -import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common'; import { transformExampleStatusInCollection } from '@usebruno/common'; import each from 'lodash/each'; -import postmanTranslation from './postman-translations'; +import get from 'lodash/get'; +import { hydrateSeqInCollection, transformItemsInCollection, uuid, validateSchema } from '../common'; +import { invalidVariableCharacterRegex } from '../constants/index'; import { - extractPackagesFromScript, buildPackageReport, + extractPackagesFromScript, TRANSLATOR_INJECTED_GLOBALS } from './postman-package-detector'; -import { invalidVariableCharacterRegex } from '../constants/index'; +import postmanTranslation from './postman-translations'; const AUTH_TYPES = Object.freeze({ BASIC: 'basic', @@ -18,6 +18,7 @@ const AUTH_TYPES = Object.freeze({ DIGEST: 'digest', OAUTH1: 'oauth1', OAUTH2: 'oauth2', + EDGEGRID: 'edgegrid', NOAUTH: 'noauth', NONE: 'none' }); @@ -91,6 +92,13 @@ const ensureString = (value, fallback = '') => { return String(value); }; +// EdgeGrid's maxBodySize is numeric in Bruno; coerce Postman's value (number or string) to a number or null. +const ensureMaxBodySize = (value) => { + if (value == null || value === '') return null; + const num = Number(value); + return isNaN(num) ? null : num; +}; + /** * Postman's schema allows headers as strings in the format "Key: Value". * This parses a single string header into an object. @@ -281,6 +289,19 @@ export const processAuth = (auth, requestObject, isCollection = false) => { password: ensureString(authValues.password) }; break; + case AUTH_TYPES.EDGEGRID: + requestObject.auth.mode = 'akamai-edgegrid'; + requestObject.auth.akamaiEdgegrid = { + accessToken: ensureString(authValues.accessToken), + clientToken: ensureString(authValues.clientToken), + clientSecret: ensureString(authValues.clientSecret), + nonce: ensureString(authValues.nonce), + timestamp: ensureString(authValues.timestamp), + baseURL: ensureString(authValues.baseURL), + headersToSign: ensureString(authValues.headersToSign), + maxBodySize: ensureMaxBodySize(authValues.maxBodySize) + }; + break; case AUTH_TYPES.OAUTH1: requestObject.auth.oauth1 = { consumerKey: ensureString(authValues.consumerKey), diff --git a/packages/bruno-converters/tests/postman/edgegrid-auth.spec.js b/packages/bruno-converters/tests/postman/edgegrid-auth.spec.js new file mode 100644 index 000000000..4680eb084 --- /dev/null +++ b/packages/bruno-converters/tests/postman/edgegrid-auth.spec.js @@ -0,0 +1,116 @@ +import { describe, it, expect } from '@jest/globals'; +import postmanToBruno from '../../src/postman/postman-to-bruno'; +import { brunoToPostman } from '../../src/postman/bruno-to-postman'; + +const EDGEGRID = { + accessToken: 'akab-access-token', + clientToken: 'akab-client-token', + clientSecret: 'secret==', + nonce: 'my-nonce', + timestamp: '20240101T00:00:00+0000', + baseURL: 'https://akaa-x.luna.akamaiapis.net', + headersToSign: 'X-Test1,X-Test2', + maxBodySize: 2048 +}; + +const brunoCollectionWithEdgeGrid = () => ({ + name: 'EdgeGrid Collection', + items: [ + { + name: 'EdgeGrid Request', + type: 'http-request', + request: { + method: 'GET', + url: 'https://akaa-x.luna.akamaiapis.net/identity/v1', + headers: [], + auth: { mode: 'akamai-edgegrid', akamaiEdgegrid: { ...EDGEGRID } } + } + } + ] +}); + +const findByKey = (arr, key) => arr.find((x) => x.key === key)?.value; + +describe('EdgeGrid — Postman export (bruno → postman)', () => { + it('produces a postman edgegrid auth block with all fields incl. maxBodySize', () => { + const result = brunoToPostman(brunoCollectionWithEdgeGrid()); + const auth = result.item[0].request.auth; + + expect(auth.type).toBe('edgegrid'); + expect(findByKey(auth.edgegrid, 'accessToken')).toBe(EDGEGRID.accessToken); + expect(findByKey(auth.edgegrid, 'clientToken')).toBe(EDGEGRID.clientToken); + expect(findByKey(auth.edgegrid, 'clientSecret')).toBe(EDGEGRID.clientSecret); + expect(findByKey(auth.edgegrid, 'baseURL')).toBe(EDGEGRID.baseURL); + expect(findByKey(auth.edgegrid, 'nonce')).toBe(EDGEGRID.nonce); + expect(findByKey(auth.edgegrid, 'timestamp')).toBe(EDGEGRID.timestamp); + expect(findByKey(auth.edgegrid, 'headersToSign')).toBe(EDGEGRID.headersToSign); + expect(findByKey(auth.edgegrid, 'maxBodySize')).toBe(EDGEGRID.maxBodySize); + }); + + it('handles empty optional fields without crashing', () => { + const collection = { + name: 'c', + items: [ + { + name: 'r', + type: 'http-request', + request: { + method: 'GET', + url: 'https://x.luna.akamaiapis.net/', + headers: [], + auth: { mode: 'akamai-edgegrid', akamaiEdgegrid: { accessToken: 'at', clientToken: 'ct', clientSecret: 'cs' } } + } + } + ] + }; + const result = brunoToPostman(collection); + const auth = result.item[0].request.auth; + expect(auth.type).toBe('edgegrid'); + expect(findByKey(auth.edgegrid, 'baseURL')).toBe(''); + expect(findByKey(auth.edgegrid, 'maxBodySize')).toBe(''); + }); +}); + +describe('EdgeGrid — Postman import (postman → bruno)', () => { + it('imports edgegrid auth at request level with all credentials', async () => { + const postmanCollection = { + info: { name: 'C', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + { + name: 'EdgeGrid Request', + request: { + method: 'GET', + url: 'https://akaa-x.luna.akamaiapis.net/identity/v1', + auth: { + type: 'edgegrid', + edgegrid: [ + { key: 'accessToken', value: EDGEGRID.accessToken }, + { key: 'clientToken', value: EDGEGRID.clientToken }, + { key: 'clientSecret', value: EDGEGRID.clientSecret }, + { key: 'baseURL', value: EDGEGRID.baseURL }, + { key: 'nonce', value: EDGEGRID.nonce }, + { key: 'timestamp', value: EDGEGRID.timestamp }, + { key: 'headersToSign', value: EDGEGRID.headersToSign }, + { key: 'maxBodySize', value: EDGEGRID.maxBodySize } + ] + } + } + } + ] + }; + const { collection } = await postmanToBruno(postmanCollection); + const auth = collection.items[0].request.auth; + expect(auth.mode).toBe('akamai-edgegrid'); + expect(auth.akamaiEdgegrid).toEqual(EDGEGRID); + }); +}); + +describe('EdgeGrid — Postman round-trip (bruno → postman → bruno)', () => { + it('preserves all 8 fields including maxBodySize', async () => { + const postman = brunoToPostman(brunoCollectionWithEdgeGrid()); + const { collection } = await postmanToBruno(postman); + const auth = collection.items[0].request.auth; + expect(auth.mode).toBe('akamai-edgegrid'); + expect(auth.akamaiEdgegrid).toEqual(EDGEGRID); + }); +}); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js index ddfae1511..923d9e496 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js @@ -690,4 +690,66 @@ describe('processAuth', () => { processAuth(auth, requestObject); expect(requestObject.auth.oauth1.placement).toBe('query'); }); + + it('should handle edgegrid auth (Postman v2.1 array form)', () => { + const auth = { + type: 'edgegrid', + edgegrid: [ + { key: 'accessToken', value: 'akab-access-token', type: 'string' }, + { key: 'clientToken', value: 'akab-client-token', type: 'string' }, + { key: 'clientSecret', value: 'secret==', type: 'string' }, + { key: 'baseURL', value: 'https://akaa-x.luna.akamaiapis.net', type: 'string' }, + { key: 'nonce', value: 'my-nonce', type: 'string' }, + { key: 'timestamp', value: '20240101T00:00:00+0000', type: 'string' }, + { key: 'headersToSign', value: 'X-Test1,X-Test2', type: 'string' }, + { key: 'maxBodySize', value: '2048', type: 'string' } + ] + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('akamai-edgegrid'); + expect(requestObject.auth.akamaiEdgegrid).toEqual({ + accessToken: 'akab-access-token', + clientToken: 'akab-client-token', + clientSecret: 'secret==', + nonce: 'my-nonce', + timestamp: '20240101T00:00:00+0000', + baseURL: 'https://akaa-x.luna.akamaiapis.net', + headersToSign: 'X-Test1,X-Test2', + maxBodySize: 2048 + }); + }); + + it('should handle edgegrid auth (object form)', () => { + const auth = { + type: 'edgegrid', + edgegrid: { accessToken: 'at', clientToken: 'ct', clientSecret: 'cs' } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('akamai-edgegrid'); + expect(requestObject.auth.akamaiEdgegrid).toEqual({ + accessToken: 'at', + clientToken: 'ct', + clientSecret: 'cs', + nonce: '', + timestamp: '', + baseURL: '', + headersToSign: '', + maxBodySize: null + }); + }); + + it('should handle edgegrid auth with missing edgegrid key', () => { + processAuth({ type: 'edgegrid' }, requestObject); + expect(requestObject.auth.mode).toBe('akamai-edgegrid'); + expect(requestObject.auth.akamaiEdgegrid).toEqual({ + accessToken: '', + clientToken: '', + clientSecret: '', + nonce: '', + timestamp: '', + baseURL: '', + headersToSign: '', + maxBodySize: null + }); + }); }); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index ffdee4066..3aeb44099 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -15,7 +15,7 @@ const { encodeUrl, hasExplicitScheme } = require('@usebruno/common').utils; const { extractPromptVariables } = require('@usebruno/common').utils; const { interpolateString } = require('./interpolate-string'); const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper'); -const { addDigestInterceptor } = require('@usebruno/requests'); +const { addDigestInterceptor, addEdgeGridInterceptor } = require('@usebruno/requests'); const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request'); const { prepareRequest } = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); @@ -315,6 +315,11 @@ const configureRequest = async ( addDigestInterceptor(axiosInstance, request); } + if (request.edgeGridConfig) { + addEdgeGridInterceptor(axiosInstance, request); + delete request.edgeGridConfig; + } + // Get timeout from request settings, fallback to global preference const resolvedSettings = resolveInheritedSettings(request.settings || {}); request.timeout = resolvedSettings.timeout; diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 5d15c8625..0465e04d9 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -91,6 +91,18 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth; } break; + case 'akamai-edgegrid': + axiosRequest.edgeGridConfig = { + accessToken: get(collectionAuth, 'akamaiEdgegrid.accessToken'), + clientToken: get(collectionAuth, 'akamaiEdgegrid.clientToken'), + clientSecret: get(collectionAuth, 'akamaiEdgegrid.clientSecret'), + nonce: get(collectionAuth, 'akamaiEdgegrid.nonce'), + timestamp: get(collectionAuth, 'akamaiEdgegrid.timestamp'), + baseURL: get(collectionAuth, 'akamaiEdgegrid.baseURL'), + headersToSign: get(collectionAuth, 'akamaiEdgegrid.headersToSign'), + maxBodySize: get(collectionAuth, 'akamaiEdgegrid.maxBodySize') + }; + break; case 'oauth2': const grantType = get(collectionAuth, 'oauth2.grantType'); switch (grantType) { @@ -345,6 +357,18 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth; } break; + case 'akamai-edgegrid': + axiosRequest.edgeGridConfig = { + accessToken: get(request, 'auth.akamaiEdgegrid.accessToken'), + clientToken: get(request, 'auth.akamaiEdgegrid.clientToken'), + clientSecret: get(request, 'auth.akamaiEdgegrid.clientSecret'), + nonce: get(request, 'auth.akamaiEdgegrid.nonce'), + timestamp: get(request, 'auth.akamaiEdgegrid.timestamp'), + baseURL: get(request, 'auth.akamaiEdgegrid.baseURL'), + headersToSign: get(request, 'auth.akamaiEdgegrid.headersToSign'), + maxBodySize: get(request, 'auth.akamaiEdgegrid.maxBodySize') + }; + break; } } diff --git a/packages/bruno-filestore/src/formats/yml/common/auth.ts b/packages/bruno-filestore/src/formats/yml/common/auth.ts index 884dd41df..f8ad1e0c8 100644 --- a/packages/bruno-filestore/src/formats/yml/common/auth.ts +++ b/packages/bruno-filestore/src/formats/yml/common/auth.ts @@ -13,6 +13,20 @@ import type { Auth as BrunoAuth, AuthOauth1 as BrunoAuthOauth1 } from '@usebruno import { isString } from '../../../utils'; import { toOpenCollectionOAuth2, toBrunoOAuth2 } from './auth-oauth2'; +// EdgeGrid is a Bruno-specific auth mode that is not part of the OpenCollection spec, +// so its shape is defined locally and serialized using the same field names as Bruno. +interface AkamaiEdgeGridAuthValues { + type: 'akamai-edgegrid'; + accessToken?: string; + clientToken?: string; + clientSecret?: string; + nonce?: string; + timestamp?: string; + baseURL?: string; + headersToSign?: string; + maxBodySize?: number; +} + const buildAwsV4Auth = (config?: BrunoAuth['awsv4']): AuthAwsV4 => { const auth: AuthAwsV4 = { type: 'awsv4' }; @@ -116,6 +130,25 @@ const buildApiKeyAuth = (config?: BrunoAuth['apikey']): AuthApiKey => { return auth; }; +const buildEdgeGridAuth = (config?: BrunoAuth['akamaiEdgegrid']): AkamaiEdgeGridAuthValues => { + const auth: AkamaiEdgeGridAuthValues = { type: 'akamai-edgegrid' }; + + if (!config) { + return auth; + } + + if (isString(config.accessToken)) auth.accessToken = config.accessToken; + if (isString(config.clientToken)) auth.clientToken = config.clientToken; + if (isString(config.clientSecret)) auth.clientSecret = config.clientSecret; + if (isString(config.nonce)) auth.nonce = config.nonce; + if (isString(config.timestamp)) auth.timestamp = config.timestamp; + if (isString(config.baseURL)) auth.baseURL = config.baseURL; + if (isString(config.headersToSign)) auth.headersToSign = config.headersToSign; + if (typeof config.maxBodySize === 'number') auth.maxBodySize = config.maxBodySize; + + return auth; +}; + const buildOAuth1Auth = (config?: BrunoAuth['oauth1']): AuthOAuth1 => { const auth: AuthOAuth1 = { type: 'oauth1' }; @@ -173,6 +206,8 @@ export const toOpenCollectionAuth = (auth?: BrunoAuth | null): Auth | undefined return buildOAuth1Auth(auth.oauth1); case 'oauth2': return toOpenCollectionOAuth2(auth.oauth2); + case 'akamai-edgegrid': + return buildEdgeGridAuth(auth.akamaiEdgegrid) as unknown as Auth; default: console.warn(`toOpenCollectionAuth failed: Unsupported auth mode "${auth.mode}".`); return undefined; @@ -189,7 +224,8 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null => ntlm: null, oauth2: null, wsse: null, - apikey: null + apikey: null, + akamaiEdgegrid: null }; if (!auth) { @@ -201,6 +237,22 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null => return brunoAuth; } + if ((auth as unknown as AkamaiEdgeGridAuthValues).type === 'akamai-edgegrid') { + const edgegrid = auth as unknown as AkamaiEdgeGridAuthValues; + brunoAuth.mode = 'akamai-edgegrid'; + brunoAuth.akamaiEdgegrid = { + accessToken: edgegrid.accessToken || '', + clientToken: edgegrid.clientToken || '', + clientSecret: edgegrid.clientSecret || '', + nonce: edgegrid.nonce || '', + timestamp: edgegrid.timestamp || '', + baseURL: edgegrid.baseURL || '', + headersToSign: edgegrid.headersToSign || '', + maxBodySize: typeof edgegrid.maxBodySize === 'number' ? edgegrid.maxBodySize : null + }; + return brunoAuth; + } + switch (auth.type) { case 'awsv4': brunoAuth.mode = 'awsv4'; diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 19f86597b..67c70e032 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -35,7 +35,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); */ const grammar = ohm.grammar(`Bru { BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | app | settings | docs | example)* - auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs + auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws bodyforms = bodyformurlencoded | bodymultipart | bodyfile params = paramspath | paramsquery @@ -147,6 +147,7 @@ const grammar = ohm.grammar(`Bru { authOAuth2 = "auth:oauth2" dictionary authwsse = "auth:wsse" dictionary authapikey = "auth:apikey" dictionary + authedgegrid = "auth:akamai-edgegrid" dictionary oauth2AuthReqHeaders = "auth:oauth2:additional_params:auth_req:headers" dictionary oauth2AuthReqQueryParams = "auth:oauth2:additional_params:auth_req:queryparams" dictionary @@ -1005,6 +1006,39 @@ const sem = grammar.createSemantics().addAttribute('ast', { } }; }, + authedgegrid(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + + const findValueByName = (name) => { + const item = _.find(auth, { name }); + return item ? item.value : ''; + }; + + const accessToken = findValueByName('accessToken'); + const clientToken = findValueByName('clientToken'); + const clientSecret = findValueByName('clientSecret'); + const nonce = findValueByName('nonce'); + const timestamp = findValueByName('timestamp'); + const baseURL = findValueByName('baseURL'); + const headersToSign = findValueByName('headersToSign'); + const maxBodySizeRaw = findValueByName('maxBodySize'); + const maxBodySize = maxBodySizeRaw === '' || isNaN(Number(maxBodySizeRaw)) ? null : Number(maxBodySizeRaw); + + return { + auth: { + akamaiEdgegrid: { + accessToken, + clientToken, + clientSecret, + nonce, + timestamp, + baseURL, + headersToSign, + maxBodySize + } + } + }; + }, bodyformurlencoded(_1, dictionary) { return { body: { diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index 6b9644e6b..b53d490ad 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -8,7 +8,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); const grammar = ohm.grammar(`Bru { BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* - auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs + auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs // Oauth2 additional parameters authOauth2Configs = oauth2AuthReqConfig | oauth2AccessTokenReqConfig | oauth2RefreshTokenReqConfig @@ -93,7 +93,8 @@ const grammar = ohm.grammar(`Bru { authOAuth2 = "auth:oauth2" dictionary authwsse = "auth:wsse" dictionary authapikey = "auth:apikey" dictionary - + authedgegrid = "auth:akamai-edgegrid" dictionary + script = scriptreq | scriptres scriptreq = "script:pre-request" st* "{" nl* textblock tagend scriptres = "script:post-response" st* "{" nl* textblock tagend @@ -606,6 +607,39 @@ const sem = grammar.createSemantics().addAttribute('ast', { } }; }, + authedgegrid(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + + const findValueByName = (name) => { + const item = _.find(auth, { name }); + return item ? item.value : ''; + }; + + const accessToken = findValueByName('accessToken'); + const clientToken = findValueByName('clientToken'); + const clientSecret = findValueByName('clientSecret'); + const nonce = findValueByName('nonce'); + const timestamp = findValueByName('timestamp'); + const baseURL = findValueByName('baseURL'); + const headersToSign = findValueByName('headersToSign'); + const maxBodySizeRaw = findValueByName('maxBodySize'); + const maxBodySize = maxBodySizeRaw === '' || isNaN(Number(maxBodySizeRaw)) ? null : Number(maxBodySizeRaw); + + return { + auth: { + akamaiEdgegrid: { + accessToken, + clientToken, + clientSecret, + nonce, + timestamp, + baseURL, + headersToSign, + maxBodySize + } + } + }; + }, varsreq(_1, dictionary) { const vars = mapPairListToKeyValPairs(dictionary.ast, true, true); _.each(vars, (v) => { diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index a30b1fb4a..da2cdfa50 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -487,6 +487,21 @@ ${indentString(`value: ${auth?.apikey?.value || ''}`)} ${indentString(`placement: ${auth?.apikey?.placement || ''}`)} } +`; + } + + if (auth && auth.akamaiEdgegrid) { + bru += `auth:akamai-edgegrid { +${indentString(`accessToken: ${auth?.akamaiEdgegrid?.accessToken || ''}`)} +${indentString(`clientToken: ${auth?.akamaiEdgegrid?.clientToken || ''}`)} +${indentString(`clientSecret: ${auth?.akamaiEdgegrid?.clientSecret || ''}`)} +${indentString(`nonce: ${auth?.akamaiEdgegrid?.nonce || ''}`)} +${indentString(`timestamp: ${auth?.akamaiEdgegrid?.timestamp || ''}`)} +${indentString(`baseURL: ${auth?.akamaiEdgegrid?.baseURL || ''}`)} +${indentString(`headersToSign: ${auth?.akamaiEdgegrid?.headersToSign || ''}`)} +${indentString(`maxBodySize: ${auth?.akamaiEdgegrid?.maxBodySize ?? ''}`)} +} + `; } diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js index 6e69cf0f2..2bb5c646f 100644 --- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -140,6 +140,21 @@ ${indentString(`key: ${auth?.apikey?.key || ''}`)} ${indentString(`value: ${auth?.apikey?.value || ''}`)} ${indentString(`placement: ${auth?.apikey?.placement || ''}`)} } +`; + } + + if (auth && auth.akamaiEdgegrid) { + bru += `auth:akamai-edgegrid { +${indentString(`accessToken: ${auth?.akamaiEdgegrid?.accessToken || ''}`)} +${indentString(`clientToken: ${auth?.akamaiEdgegrid?.clientToken || ''}`)} +${indentString(`clientSecret: ${auth?.akamaiEdgegrid?.clientSecret || ''}`)} +${indentString(`nonce: ${auth?.akamaiEdgegrid?.nonce || ''}`)} +${indentString(`timestamp: ${auth?.akamaiEdgegrid?.timestamp || ''}`)} +${indentString(`baseURL: ${auth?.akamaiEdgegrid?.baseURL || ''}`)} +${indentString(`headersToSign: ${auth?.akamaiEdgegrid?.headersToSign || ''}`)} +${indentString(`maxBodySize: ${auth?.akamaiEdgegrid?.maxBodySize ?? ''}`)} +} + `; } diff --git a/packages/bruno-requests/src/auth/edgegrid-helper.js b/packages/bruno-requests/src/auth/edgegrid-helper.js new file mode 100644 index 000000000..171a2558f --- /dev/null +++ b/packages/bruno-requests/src/auth/edgegrid-helper.js @@ -0,0 +1,214 @@ +const crypto = require('crypto'); +const { URL } = require('node:url'); + +/** + * Akamai EdgeGrid Authentication Helper + * Implements the EG1-HMAC-SHA256 scheme exactly as Akamai's official client library + * (https://github.com/akamai/AkamaiOPEN-edgegrid-node) so signatures validate against + * the real Akamai gateway. + * Spec: https://techdocs.akamai.com/developer/docs/authenticate-with-edgegrid + */ + +const MAX_BODY_SIZE_DEFAULT = 131072; // 128 KB + +function isStrPresent(str) { + return str && str.trim() !== '' && str.trim() !== 'undefined'; +} + +/** + * Generate a UTC timestamp in EdgeGrid format: YYYYMMDDTHH:MM:SS+0000 + * (date part has no separators, time part keeps colons, fixed +0000 offset). + * @returns {string} + */ +function makeEdgeGridTimestamp() { + const [datePart, timePart] = new Date().toISOString().split('T'); + const date = datePart.replace(/-/g, ''); + const time = timePart.replace(/\.\d+Z$/, ''); + return `${date}T${time}+0000`; +} + +/** + * Generate a random nonce (UUID v4) + * @returns {string} + */ +function makeEdgeGridNonce() { + return crypto.randomUUID(); +} + +/** + * Base64-encoded HMAC-SHA256. Note the result is a base64 STRING; when used as the + * signing key it is passed (as a string) as the key of the next HMAC — matching Akamai. + * @param {string|Buffer} data + * @param {string} key + * @returns {string} + */ +function base64HmacSha256(data, key) { + return crypto.createHmac('sha256', key).update(data).digest('base64'); +} + +/** + * Base64-encoded SHA256 hash. + * @param {string|Buffer} data + * @returns {string} + */ +function base64Sha256(data) { + return crypto.createHash('sha256').update(data).digest('base64'); +} + +/** + * Build the canonicalized headers segment from the comma-separated list of header NAMES + * the user asked to sign, pulling the actual values off the outgoing request. + * Format per header: `name(lowercase):value(trimmed, internal whitespace collapsed)`, + * joined by a tab — matching Akamai's canonicalizeHeaders(). + * @param {string} headersToSign - comma-separated header names + * @param {Object} requestHeaders - outgoing request headers ({ name: value }) + * @returns {string} + */ +function canonicalizeHeaders(headersToSign, requestHeaders = {}) { + if (!isStrPresent(headersToSign)) { + return ''; + } + + // case-insensitive lookup of the actual request header values + const lookup = {}; + Object.keys(requestHeaders || {}).forEach((name) => { + lookup[name.toLowerCase()] = requestHeaders[name]; + }); + + return headersToSign + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter((name) => name.length > 0) + // Akamai only signs headers that are actually present on the request (config order preserved); + // names not present are skipped, not emitted as empty values. + .filter((name) => Object.prototype.hasOwnProperty.call(lookup, name)) + .map((name) => `${name}:${String(lookup[name]).trim().replace(/\s+/g, ' ')}`) + .join('\t'); +} + +/** + * Compute the request body content hash. Akamai hashes the body for POST requests only, + * over the exact bytes that are sent on the wire (no re-serialization), truncated to + * maxBodySize before hashing. + * @param {Object} request - axios request config ({ method, data }) + * @param {number} maxBodySize + * @returns {string} base64 SHA256 of the (possibly truncated) body, or '' when not applicable + */ +function makeContentHash(request, maxBodySize) { + if (!request.method || request.method.toUpperCase() !== 'POST') { + return ''; + } + + let body = request.data; + if (!body) { + return ''; + } + // Hash the bytes as sent — do NOT parse/re-stringify (that would change the payload). + body = typeof body === 'string' ? body : JSON.stringify(body); + if (body.length === 0) { + return ''; + } + if (body.length > maxBodySize) { + body = body.substring(0, maxBodySize); + } + return base64Sha256(body); +} + +/** + * Sign an EdgeGrid request. + * @param {Object} config - EdgeGrid configuration + * @param {string} config.accessToken + * @param {string} config.clientToken + * @param {string} config.clientSecret + * @param {string} [config.baseURL] - override host the request is signed against + * @param {string} [config.nonce] - optional nonce override + * @param {string} [config.timestamp] - optional timestamp override + * @param {string} [config.headersToSign] - comma-separated header names to sign + * @param {string|number} [config.maxBodySize=131072] + * @param {Object} request - axios request config ({ method, url, headers, data }) + * @returns {string} Authorization header value + */ +export function signEdgeGridRequest(config, request) { + const { accessToken, clientToken, clientSecret, baseURL, headersToSign } = config; + const maxBodySize = config.maxBodySize ? parseInt(config.maxBodySize, 10) : MAX_BODY_SIZE_DEFAULT; + + // Validate required fields + if (!isStrPresent(accessToken)) { + throw new Error('EdgeGrid: accessToken is required'); + } + if (!isStrPresent(clientToken)) { + throw new Error('EdgeGrid: clientToken is required'); + } + if (!isStrPresent(clientSecret)) { + throw new Error('EdgeGrid: clientSecret is required'); + } + + // Generate or use provided nonce and timestamp + const nonce = isStrPresent(config.nonce) ? config.nonce : makeEdgeGridNonce(); + const timestamp = isStrPresent(config.timestamp) ? config.timestamp : makeEdgeGridTimestamp(); + + // Determine the URL to sign — use baseURL's host/protocol if provided, otherwise the request URL. + let urlToSign = request.url; + if (isStrPresent(baseURL)) { + const requestUrl = new URL(request.url); + // A scheme-less baseURL like "localhost:6000" mis-parses ("localhost:" becomes the protocol + // and the host is empty). If there's no "scheme://", borrow the request URL's scheme. + const normalizedBaseURL = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseURL.trim()) + ? baseURL.trim() + : `${requestUrl.protocol}//${baseURL.trim()}`; + const baseParsed = new URL(normalizedBaseURL); + urlToSign = `${baseParsed.protocol}//${baseParsed.host}${requestUrl.pathname}${requestUrl.search}`; + } + const parsedUrl = new URL(urlToSign); + + // Auth header (without the signature) — this exact string is also the LAST field of the + // data-to-sign, per the EdgeGrid spec / official library. + const authHeader + = `EG1-HMAC-SHA256 client_token=${clientToken};access_token=${accessToken};timestamp=${timestamp};nonce=${nonce};`; + + // data-to-sign: tab-joined, in the exact order Akamai expects. + const dataToSign = [ + request.method.toUpperCase(), + parsedUrl.protocol.replace(':', ''), + parsedUrl.host, + parsedUrl.pathname + parsedUrl.search, + canonicalizeHeaders(headersToSign, request.headers), + makeContentHash(request, maxBodySize), + authHeader + ].join('\t'); + + // Signing key is the base64 STRING of HMAC(timestamp, clientSecret); the signature is then + // HMAC(dataToSign, signingKey) — both base64-encoded. + const signingKey = base64HmacSha256(timestamp, clientSecret); + const signature = base64HmacSha256(dataToSign, signingKey); + + return `${authHeader}signature=${signature}`; +} + +/** + * Add EdgeGrid interceptor to axios instance + * @param {Object} axiosInstance - Axios instance + * @param {Object} request - Request object with edgeGridConfig + */ +export function addEdgeGridInterceptor(axiosInstance, request) { + const { edgeGridConfig } = request; + + if (!edgeGridConfig) { + return; + } + + // Add request interceptor to sign requests + axiosInstance.interceptors.request.use((config) => { + try { + const authHeader = signEdgeGridRequest(edgeGridConfig, config); + config.headers['Authorization'] = authHeader; + return config; + } catch (error) { + console.error('EdgeGrid signing error:', error); + return Promise.reject(error); + } + }, + (error) => { + return Promise.reject(error); + }); +} diff --git a/packages/bruno-requests/src/auth/edgegrid-helper.spec.js b/packages/bruno-requests/src/auth/edgegrid-helper.spec.js new file mode 100644 index 000000000..0a1743321 --- /dev/null +++ b/packages/bruno-requests/src/auth/edgegrid-helper.spec.js @@ -0,0 +1,197 @@ +const { signEdgeGridRequest } = require('./edgegrid-helper'); + +/** + * These vectors are Akamai's OFFICIAL EdgeGrid test fixtures, taken from the reference + * implementation (github.com/akamai/AkamaiOPEN-edgegrid-node — test/test.js & test/test_data.json). + * Matching them byte-for-byte proves Bruno's signature is interoperable with the real + * Akamai gateway. Credentials/nonce/timestamp are the published dummy values. + */ +const CREDS = { + clientToken: 'akab-client-token-xxx-xxxxxxxxxxxxxxxx', + accessToken: 'akab-access-token-xxx-xxxxxxxxxxxxxxxx', + clientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=', + nonce: 'nonce-xx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + timestamp: '20140321T19:34:21+0000' +}; +const HOST = 'https://akaa-baseurl-xxxxxxxxxxx-xxxxxxxxxxxxx.luna.akamaiapis.net'; +const HEADERS_TO_SIGN = 'X-Test1,X-Test2,X-Test3'; + +const sig = (header) => header.split('signature=')[1]; + +describe('signEdgeGridRequest — Akamai official vectors (core)', () => { + const cases = [ + { + name: 'simple GET', + config: CREDS, + request: { method: 'GET', url: `${HOST}/` }, + expected: 'tL+y4hxyHxgWVD30X3pWnGKHcPzmrIF+LThiAOhMxYU=' + }, + { + name: 'GET with query string', + config: CREDS, + request: { method: 'GET', url: `${HOST}/testapi/v1/t1?p1=1&p2=2` }, + expected: 'hKDH1UlnQySSHjvIcZpDMbQHihTQ0XyVAKZaApabdeA=' + }, + { + name: 'POST within body limit (body hashed)', + config: CREDS, + request: { method: 'POST', url: `${HOST}/testapi/v1/t3`, data: 'datadatadatadatadatadatadatadata' }, + expected: 'hXm4iCxtpN22m4cbZb4lVLW5rhX8Ca82vCFqXzSTPe4=' + }, + { + name: 'POST with empty body (no content hash)', + config: CREDS, + request: { method: 'POST', url: `${HOST}/testapi/v1/t6`, data: '' }, + expected: '1gEDxeQGD5GovIkJJGcBaKnZ+VaPtrc4qBUHixjsPCQ=' + } + ]; + + test.each(cases)('$name', ({ config, request, expected }) => { + expect(sig(signEdgeGridRequest(config, request))).toBe(expected); + }); +}); + +describe('signEdgeGridRequest — Akamai official vectors (headers_to_sign)', () => { + const config = { ...CREDS, headersToSign: HEADERS_TO_SIGN, maxBodySize: 2048 }; + const cases = [ + { + name: 'single signed header', + request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': 'test-simple-header' } }, + expected: '8F9AybcRw+PLxnvT+H0JRkjROrrUgsxJTnRXMzqvcwY=' + }, + { + name: 'header value with surrounding/quoted spaces (trimmed + collapsed)', + request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': '" test-header-with-spaces "' } }, + expected: 'ucq2AbjCNtobHfCTuS38fdkl5UDdWHZhQX46fYR8CqI=' + }, + { + name: 'header with leading + interior spaces (collapsed)', + request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': ' first-thing second-thing' } }, + expected: 'WtnneL539UadAAOJwnsXvPqT4Kt6z7HMgBEwAFpt3+c=' + }, + { + name: 'headers signed in config order regardless of request order', + request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3' } }, + expected: 'Wus73Nx8jOYM+kkBFF2q8D1EATRIMr0WLWwpLBgkBqY=' + }, + { + name: 'headers not in the sign list are excluded', + request: { + method: 'GET', + url: `${HOST}/testapi/v1/t5`, + headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3', 'X-Extra': 'this won\'t be included' } + }, + expected: 'Knd/jc0A5Ghhizjayr0AUUvl2MZjBpS3FDSzvtq4Ixc=' + } + ]; + + test.each(cases)('$name', ({ request, expected }) => { + expect(sig(signEdgeGridRequest(config, request))).toBe(expected); + }); +}); + +describe('signEdgeGridRequest — body hashing rules', () => { + test('non-POST methods are NOT body-hashed (PUT with data)', () => { + const header = signEdgeGridRequest(CREDS, { + method: 'PUT', + url: `${HOST}/testapi/v1/t6`, + data: 'PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP' + }); + // Matches the Akamai fixture for a PUT (no content hash since only POST is hashed). + expect(sig(header)).toBe('GNBWEYSEWOLtu+7dD52da2C39aX/Jchpon3K/AmBqBU='); + }); + + test('POST body is hashed as-sent (no JSON re-compaction)', () => { + const pretty = '{\n "name": "bruno"\n}'; + const compact = '{"name":"bruno"}'; + const req = (data) => ({ method: 'POST', url: `${HOST}/x`, data }); + // Different bytes on the wire ⇒ different signature (proves we hash what we send). + expect(sig(signEdgeGridRequest(CREDS, req(pretty)))).not.toBe(sig(signEdgeGridRequest(CREDS, req(compact)))); + }); + + test('POST body is truncated to maxBodySize before hashing', () => { + const config = { ...CREDS, maxBodySize: 16 }; + const a = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'AAAAAAAA' }; + const b = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'BBBBBBBB' }; + // Bytes beyond maxBodySize are ignored ⇒ identical signature. + expect(sig(signEdgeGridRequest(config, a))).toBe(sig(signEdgeGridRequest(config, b))); + }); +}); + +describe('signEdgeGridRequest — config behaviour', () => { + test('throws when accessToken is missing', () => { + expect(() => signEdgeGridRequest({ ...CREDS, accessToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow( + /accessToken is required/ + ); + }); + + test('throws when clientToken is missing', () => { + expect(() => signEdgeGridRequest({ ...CREDS, clientToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow( + /clientToken is required/ + ); + }); + + test('throws when clientSecret is missing', () => { + expect(() => signEdgeGridRequest({ ...CREDS, clientSecret: '' }, { method: 'GET', url: `${HOST}/` })).toThrow( + /clientSecret is required/ + ); + }); + + test('auto-generates nonce and timestamp when not provided', () => { + const header = signEdgeGridRequest( + { clientToken: CREDS.clientToken, accessToken: CREDS.accessToken, clientSecret: CREDS.clientSecret }, + { method: 'GET', url: `${HOST}/` } + ); + // a UUID v4 nonce and an EdgeGrid timestamp (YYYYMMDDTHH:MM:SS+0000) are present + expect(header).toMatch(/nonce=[0-9a-f-]{36};/); + expect(header).toMatch(/timestamp=\d{8}T\d{2}:\d{2}:\d{2}\+0000;/); + expect(header).toMatch(/signature=.+$/); + }); + + test('baseURL overrides the host the request is signed against', () => { + // Signing the request URL directly vs. signing via a different baseURL host ⇒ different signature + const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${HOST}/path` }); + const viaBase = signEdgeGridRequest( + { ...CREDS, baseURL: 'https://other-host.luna.akamaiapis.net' }, + { method: 'GET', url: `${HOST}/path` } + ); + expect(sig(direct)).not.toBe(sig(viaBase)); + }); +}); + +/** + * Equivalents of httpie-edgegrid's test_localhost / test_http_to_https_conversion: the EFFECTIVE + * signed URL is the baseURL host + the request's path/query, so the literal request host (e.g. + * "localhost") is replaced. We prove this by asserting the signature equals signing the fully resolved absolute URL directly. + * Reference : https://github.com/akamai/httpie-edgegrid/blob/master/test/test_httpie_edgegrid.py + */ +describe('signEdgeGridRequest — baseURL host/scheme override (httpie test_localhost equivalents)', () => { + const REAL_HOST = 'https://realhost-xxxxxxxx.luna.akamaiapis.net'; + + test('localhost is replaced by the baseURL host (test_localhost)', () => { + const viaBase = signEdgeGridRequest( + { ...CREDS, baseURL: REAL_HOST }, + { method: 'GET', url: 'https://localhost/identity-management/v3/user-profile?q=1' } + ); + const direct = signEdgeGridRequest(CREDS, { + method: 'GET', + url: `${REAL_HOST}/identity-management/v3/user-profile?q=1` + }); + expect(sig(viaBase)).toBe(sig(direct)); + }); + + test('an http request is signed against the https baseURL (test_http_to_https_conversion)', () => { + const viaBase = signEdgeGridRequest({ ...CREDS, baseURL: REAL_HOST }, { method: 'GET', url: 'http://localhost/path' }); + const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${REAL_HOST}/path` }); + expect(sig(viaBase)).toBe(sig(direct)); + }); + + test('scheme-less baseURL ("host:port") borrows the request scheme', () => { + const viaBase = signEdgeGridRequest( + { ...CREDS, baseURL: 'localhost:6000' }, + { method: 'GET', url: 'http://localhost:9999/path' } + ); + const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: 'http://localhost:6000/path' }); + expect(sig(viaBase)).toBe(sig(direct)); + }); +}); diff --git a/packages/bruno-requests/src/auth/index.ts b/packages/bruno-requests/src/auth/index.ts index 098779c1c..de87e759d 100644 --- a/packages/bruno-requests/src/auth/index.ts +++ b/packages/bruno-requests/src/auth/index.ts @@ -1,3 +1,4 @@ export { addDigestInterceptor } from './digestauth-helper'; export { getOAuth2Token } from './oauth2-helper'; export { createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './oauth1-request-authorization'; +export { addEdgeGridInterceptor, signEdgeGridRequest } from './edgegrid-helper'; diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index a71cc7481..e3598e93a 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -1,4 +1,4 @@ -export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './auth'; +export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest, addEdgeGridInterceptor } from './auth'; export { GrpcClient, generateGrpcSampleMessage } from './grpc'; export { WsClient } from './ws/ws-client'; export { default as cookies } from './cookies'; diff --git a/packages/bruno-schema-types/src/common/auth.ts b/packages/bruno-schema-types/src/common/auth.ts index 5b12a7233..e61bc8906 100644 --- a/packages/bruno-schema-types/src/common/auth.ts +++ b/packages/bruno-schema-types/src/common/auth.ts @@ -38,6 +38,17 @@ export interface AuthApiKey { placement?: 'header' | 'queryparams' | null; } +export interface AkamaiEdgeGridAuthValues { + accessToken?: string | null; + clientToken?: string | null; + clientSecret?: string | null; + nonce?: string | null; + timestamp?: string | null; + baseURL?: string | null; + headersToSign?: string | null; + maxBodySize?: number | null; +} + export interface AuthOauth1 { consumerKey?: string | null; consumerSecret?: string | null; @@ -110,7 +121,8 @@ export type AuthMode | 'oauth1' | 'oauth2' | 'wsse' - | 'apikey'; + | 'apikey' + | 'akamai-edgegrid'; export interface Auth { mode: AuthMode; @@ -123,4 +135,5 @@ export interface Auth { oauth2?: OAuth2 | null; wsse?: AuthWsse | null; apikey?: AuthApiKey | null; + akamaiEdgegrid?: AkamaiEdgeGridAuthValues | null; } diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 36a140f33..d3c98f3ca 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -261,6 +261,19 @@ const authApiKeySchema = Yup.object({ .noUnknown(true) .strict(); +const authEdgeGridSchema = Yup.object({ + accessToken: Yup.string().nullable(), + clientToken: Yup.string().nullable(), + clientSecret: Yup.string().nullable(), + nonce: Yup.string().nullable(), + timestamp: Yup.string().nullable(), + baseURL: Yup.string().nullable(), + headersToSign: Yup.string().nullable(), + maxBodySize: Yup.number().nullable() +}) + .noUnknown(true) + .strict(); + const authOAuth1Schema = Yup.object({ consumerKey: Yup.string().nullable(), consumerSecret: Yup.string().nullable(), @@ -419,7 +432,7 @@ const oauth2Schema = Yup.object({ const authSchema = Yup.object({ mode: Yup.string() - .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth1', 'oauth2', 'wsse', 'apikey']) + .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth1', 'oauth2', 'wsse', 'apikey', 'akamai-edgegrid']) .required('mode is required'), awsv4: authAwsV4Schema.nullable(), basic: authBasicSchema.nullable(), @@ -429,7 +442,8 @@ const authSchema = Yup.object({ oauth1: authOAuth1Schema.nullable(), oauth2: oauth2Schema.nullable(), wsse: authWsseSchema.nullable(), - apikey: authApiKeySchema.nullable() + apikey: authApiKeySchema.nullable(), + akamaiEdgegrid: authEdgeGridSchema.nullable() }) .noUnknown(true) .strict() diff --git a/packages/bruno-tests/src/auth/edgegrid.js b/packages/bruno-tests/src/auth/edgegrid.js new file mode 100644 index 000000000..5c1e349f0 --- /dev/null +++ b/packages/bruno-tests/src/auth/edgegrid.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * Local Akamai EdgeGrid (EG1-HMAC-SHA256) verification server — a stand-in for the real + * Akamai gateway so EdgeGrid auth can be tested end-to-end without a paid Akamai control API. + * + * It re-derives the signature from the received request + the known client secret (per the + * official EdgeGrid algorithm) and compares. Valid requests → 200, anything else → 401. + * + * Three things the Authorization header does NOT carry are supplied via test-control headers + * (these are never part of headers_to_sign, so they don't affect the client's signature): + * - x-eg-headers-to-sign : comma-separated header names the client signed (default: none) + * - x-eg-max-body-size : max body size the client used (default: 131072) + * For base_url overrides, point the collection's base_url at this server's host so the signed + * host matches the received host. + * + * This is an INDEPENDENT implementation of the spec (it does not import @usebruno/requests), + * so it also guards against regressions in the shared signing helper. + */ + +const express = require('express'); +const crypto = require('crypto'); +const { URL } = require('node:url'); + +const router = express.Router(); + +// Known test credentials. Fixtures import TEST_EDGEGRID so both sides share the secret. +const TEST_EDGEGRID = { + clientToken: 'akab-client-token-bruno-test', + accessToken: 'akab-access-token-bruno-test', + clientSecret: 'dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA==' +}; + +const MAX_BODY_SIZE_DEFAULT = 131072; + +const base64HmacSha256 = (data, key) => crypto.createHmac('sha256', key).update(data).digest('base64'); +const base64Sha256 = (data) => crypto.createHash('sha256').update(data).digest('base64'); + +function parseAuthHeader(header) { + if (!header || !header.startsWith('EG1-HMAC-SHA256 ')) return null; + const out = {}; + header + .slice('EG1-HMAC-SHA256 '.length) + .split(';') + .filter(Boolean) + .forEach((pair) => { + const i = pair.indexOf('='); + if (i > -1) out[pair.slice(0, i).trim()] = pair.slice(i + 1); + }); + return out; +} + +function canonicalizeHeaders(headersToSign, requestHeaders) { + if (!headersToSign || !headersToSign.trim()) return ''; + const lookup = {}; + Object.keys(requestHeaders || {}).forEach((name) => { + lookup[name.toLowerCase()] = requestHeaders[name]; + }); + return headersToSign + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + .filter((name) => Object.prototype.hasOwnProperty.call(lookup, name)) + .map((name) => `${name}:${String(lookup[name]).trim().replace(/\s+/g, ' ')}`) + .join('\t'); +} + +function makeContentHash(method, rawBody, maxBodySize) { + if (!method || method.toUpperCase() !== 'POST') return ''; + if (!rawBody || rawBody.length === 0) return ''; + let body = rawBody; + if (body.length > maxBodySize) body = body.substring(0, maxBodySize); + return base64Sha256(body); +} + +/** + * Verify an incoming EdgeGrid-signed request. + * @returns {{ ok: true } | { ok: false, status: number, error: string }} + */ +function verifyEdgeGridRequest(req, opts = {}) { + const clients = opts.clients || { [TEST_EDGEGRID.clientToken]: TEST_EDGEGRID }; + + const parsed = parseAuthHeader(req.headers['authorization']); + if (!parsed) { + return { ok: false, status: 401, error: 'Missing or malformed EG1-HMAC-SHA256 Authorization header' }; + } + for (const field of ['client_token', 'access_token', 'timestamp', 'nonce', 'signature']) { + if (!parsed[field]) return { ok: false, status: 401, error: `Authorization missing ${field}` }; + } + + const client = clients[parsed.client_token]; + if (!client || client.accessToken !== parsed.access_token) { + return { ok: false, status: 401, error: 'Unknown client_token / access_token' }; + } + + const headersToSign = req.headers['x-eg-headers-to-sign'] || ''; + const maxBodySize = req.headers['x-eg-max-body-size'] + ? parseInt(req.headers['x-eg-max-body-size'], 10) + : MAX_BODY_SIZE_DEFAULT; + + // Reconstruct the URL the client signed (this server sits directly in front of the request). + const scheme = req.protocol; + const host = req.headers['host']; + const u = new URL(`${scheme}://${host}${req.originalUrl}`); + + const authHeader + = `EG1-HMAC-SHA256 client_token=${parsed.client_token};access_token=${parsed.access_token};` + + `timestamp=${parsed.timestamp};nonce=${parsed.nonce};`; + + const dataToSign = [ + req.method.toUpperCase(), + u.protocol.replace(':', ''), + u.host, + u.pathname + u.search, + canonicalizeHeaders(headersToSign, req.headers), + makeContentHash(req.method, req.rawBody, maxBodySize), + authHeader + ].join('\t'); + + const signingKey = base64HmacSha256(parsed.timestamp, client.clientSecret); + const expected = base64HmacSha256(dataToSign, signingKey); + + // constant-time compare + const a = Buffer.from(expected); + const b = Buffer.from(parsed.signature); + const matches = a.length === b.length && crypto.timingSafeEqual(a, b); + if (!matches) { + return { ok: false, status: 401, error: 'Signature mismatch' }; + } + // Echo back the effective values the request was signed with, so tests can assert that + // empty Advanced Settings fields were filled in with Bruno's auto-generated defaults + // (nonce → UUID v4, timestamp → now, base_url → request host, max_body_size → 131072). + return { + ok: true, + effective: { + method: req.method.toUpperCase(), + path: u.pathname + u.search, + host: u.host, + nonce: parsed.nonce, + timestamp: parsed.timestamp, + headersToSign: headersToSign, + maxBodySize + } + }; +} + +router.all('*', (req, res) => { + const result = verifyEdgeGridRequest(req); + if (!result.ok) { + return res.status(result.status).json({ authenticated: false, error: result.error }); + } + return res.status(200).json({ authenticated: true, ...result.effective }); +}); + +module.exports = router; +module.exports.verifyEdgeGridRequest = verifyEdgeGridRequest; +module.exports.TEST_EDGEGRID = TEST_EDGEGRID; diff --git a/packages/bruno-tests/src/auth/index.js b/packages/bruno-tests/src/auth/index.js index 49ecf1ade..5282689b0 100644 --- a/packages/bruno-tests/src/auth/index.js +++ b/packages/bruno-tests/src/auth/index.js @@ -9,8 +9,10 @@ const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials'); const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode'); const authOAuth2ClientCredentials = require('./oauth2/clientCredentials'); const authOAuth1 = require('./oauth1'); +const authEdgeGrid = require('./edgegrid'); router.use('/oauth1', authOAuth1); +router.use('/edgegrid', authEdgeGrid); router.use('/oauth2/password_credentials', authOAuth2PasswordCredentials); router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode); router.use('/oauth2/client_credentials', authOAuth2ClientCredentials); diff --git a/tests/auth/akamai-edgegrid/akamai-edgegrid-levels.spec.ts b/tests/auth/akamai-edgegrid/akamai-edgegrid-levels.spec.ts new file mode 100644 index 000000000..4f490d2f8 --- /dev/null +++ b/tests/auth/akamai-edgegrid/akamai-edgegrid-levels.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from '../../../playwright'; +import { + closeAllCollections, + createCollection, + createFolder, + createRequest, + getResponseBody, + openFolderRequest, + openRequest, + selectAuthMode, + selectRequestPaneTab, + sendRequestAndWaitForResponse +} from '../../utils/page'; + +/** + * EdgeGrid is configurable at collection settings, folder settings, and request level. These + * tests verify each renders without crashing (collection-level was a `null` crash regression), + * persists all 8 fields across a save, and that a request which inherits the auth actually + * signs and is accepted by the local simulator — asserting the echoed effective values. + */ + +const { TEST_EDGEGRID } = require('../../../packages/bruno-tests/src/auth/edgegrid'); +const SIMULATOR_URL = 'http://localhost:8081/api/auth/edgegrid/resource'; + +const label = (page, text: string) => page.locator('label').filter({ hasText: text }); +const fieldRow = (page, text: string) => label(page, text).locator('..'); +const editorIn = (row) => row.locator('.single-line-editor-wrapper .CodeMirror'); +const advancedHeader = (page) => page.locator('.advanced-settings-header').filter({ hasText: 'Advanced Settings' }); + +const fieldValue = (page, fieldName: string): Promise => + editorIn(fieldRow(page, fieldName)).evaluate((el: any) => el.CodeMirror.getValue()); +const expectFieldValue = async (page, fieldName: string, expected: string) => { + await expect.poll(() => fieldValue(page, fieldName)).toBe(expected); +}; +const setField = async (page, fieldName: string, value: string) => { + await editorIn(fieldRow(page, fieldName)).click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.type(value); +}; +const saveSettings = async (page) => { + await page.getByRole('button', { name: 'Save' }).click(); +}; + +const BASIC_FIELDS = ['Access Token', 'Client Token', 'Client Secret']; +const ADVANCED_FIELDS = ['Base URL', 'Nonce', 'Timestamp', 'Headers to Sign', 'Max Body Size']; + +// Valid creds + simulator host so an inheriting request validates; a distinct nonce per level +// so the echoed response proves which level's auth was used. +const valuesFor = (nonce: string): Record => ({ + 'Access Token': TEST_EDGEGRID.accessToken, + 'Client Token': TEST_EDGEGRID.clientToken, + 'Client Secret': TEST_EDGEGRID.clientSecret, + 'Base URL': 'http://localhost:8081', + 'Nonce': nonce, + 'Timestamp': '20240101T00:00:00+0000', + 'Headers to Sign': 'X-Sign', + 'Max Body Size': '4096' +}); + +// Advanced Settings is always open, so all fields are visible without expanding. +const fillAllFields = async (page, values: Record) => { + for (const name of [...BASIC_FIELDS, ...ADVANCED_FIELDS]) await setField(page, name, values[name]); +}; + +const verifyAllFields = async (page, values: Record) => { + for (const name of [...BASIC_FIELDS, ...ADVANCED_FIELDS]) await expectFieldValue(page, name, values[name]); +}; + +const expectInheritedEdgeGridSignature = async (page, expectedNonce: string) => { + await sendRequestAndWaitForResponse(page, 200); + const body = await getResponseBody(page); + expect(body).toContain(expectedNonce); // the inherited level's configured nonce was used + expect(body).toContain('20240101T00:00:00+0000'); + expect(body).toContain('localhost:8081'); +}; + +test.describe('Akamai EdgeGrid Authentication — collection, folder & inherit', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('Collection level: renders, persists, and an inheriting request signs', async ({ page, createTmpDir }) => { + test.setTimeout(120_000); + const values = valuesFor('collection-nonce'); + await createCollection(page, 'edgegrid-collection', await createTmpDir()); + + // Collection settings open automatically on creation. + await page.locator('.tab.auth').click(); + await selectAuthMode(page, 'Akamai EdgeGrid'); + + await test.step('EdgeGrid renders without crashing (regression: null edgegrid)', async () => { + for (const name of BASIC_FIELDS) await expect(label(page, name)).toBeVisible(); + await expect(advancedHeader(page)).toBeVisible(); + }); + + await test.step('Fill all fields and save', async () => { + await fillAllFields(page, values); + await saveSettings(page); + }); + + await test.step('Switching away and back restores the saved EdgeGrid credentials', async () => { + await selectAuthMode(page, 'Basic Auth'); + await selectAuthMode(page, 'Akamai EdgeGrid'); + await verifyAllFields(page, values); + }); + + await test.step('A request set to Inherit shows + signs with the collection EdgeGrid auth', async () => { + await createRequest(page, 'inherit-request', 'edgegrid-collection', { url: SIMULATOR_URL }); + await openRequest(page, 'edgegrid-collection', 'inherit-request'); + await selectRequestPaneTab(page, 'Auth'); + await selectAuthMode(page, 'Inherit'); + + await expect(page.locator('.inherit-mode-text')).toHaveText('Akamai EdgeGrid'); + await expectInheritedEdgeGridSignature(page, 'collection-nonce'); + }); + }); + + test('Folder level: renders, persists, and an inheriting request signs', async ({ page, createTmpDir }) => { + test.setTimeout(120_000); + const values = valuesFor('folder-nonce'); + await createCollection(page, 'edgegrid-folder', await createTmpDir()); + await createFolder(page, 'folder-1', 'edgegrid-folder', true); + + await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).dblclick(); + await page.locator('.tab.auth').click(); + await selectAuthMode(page, 'Akamai EdgeGrid'); + + await test.step('EdgeGrid renders at folder level', async () => { + for (const name of BASIC_FIELDS) await expect(label(page, name)).toBeVisible(); + await expect(advancedHeader(page)).toBeVisible(); + }); + + await test.step('Fill all fields and save', async () => { + await fillAllFields(page, values); + await saveSettings(page); + }); + + await test.step('Switching away and back restores the saved folder EdgeGrid credentials', async () => { + await selectAuthMode(page, 'Basic Auth'); + await selectAuthMode(page, 'Akamai EdgeGrid'); + await verifyAllFields(page, values); + }); + + await test.step('A request inside the folder set to Inherit signs with the folder EdgeGrid auth', async () => { + await createRequest(page, 'folder-inherit-request', 'folder-1', { url: SIMULATOR_URL, inFolder: true }); + await openFolderRequest(page, 'folder-1', 'folder-inherit-request'); + await selectRequestPaneTab(page, 'Auth'); + await selectAuthMode(page, 'Inherit'); + + await expect(page.locator('.inherit-mode-text')).toHaveText('Akamai EdgeGrid'); + await expectInheritedEdgeGridSignature(page, 'folder-nonce'); + }); + }); +}); diff --git a/tests/auth/akamai-edgegrid/akamai-edgegrid-runner.spec.ts b/tests/auth/akamai-edgegrid/akamai-edgegrid-runner.spec.ts new file mode 100644 index 000000000..a45285ebb --- /dev/null +++ b/tests/auth/akamai-edgegrid/akamai-edgegrid-runner.spec.ts @@ -0,0 +1,60 @@ +import { test } from '../../../playwright'; +import { + closeAllCollections, + openCollection, + openRequest, + selectEnvironment, + sendRequestAndWaitForResponse +} from '../../utils/page'; + +/** + * End-to-end EdgeGrid (EG1-HMAC-SHA256) auth against the local simulator + * (packages/bruno-tests/src/auth/edgegrid.js, mounted at /api/auth/edgegrid). The simulator + * independently re-derives the signature and returns 200/401 — so a green run proves the full + * UI → prepare-request → interceptor → wire → server-validation path, with no real Akamai API. + */ + +const BRU_COLLECTION = 'edgegrid-testbench-bru'; +const YML_COLLECTION = 'edgegrid-testbench-yml'; + +const requests = [ + { name: 'EdgeGrid GET 200', status: 200 }, + { name: 'EdgeGrid GET 401', status: 401 }, + { name: 'EdgeGrid POST JSON 200', status: 200 }, + { name: 'EdgeGrid POST Pretty JSON 200', status: 200 }, // regression: body hashed as-sent + { name: 'EdgeGrid Signed Headers 200', status: 200 }, // regression: headers_to_sign canonicalization + { name: 'EdgeGrid Base URL Override 200', status: 200 }, + { name: 'EdgeGrid Inherit 200', status: 200 } // request inherits collection-level EdgeGrid auth +]; + +const sendAllRequests = async (page, collectionName: string) => { + await openCollection(page, collectionName); + await selectEnvironment(page, 'Local', 'collection'); + + for (const { name, status } of requests) { + await test.step(name, async () => { + await openRequest(page, collectionName, name); + await sendRequestAndWaitForResponse(page, status); + }); + } +}; + +test.describe('Akamai EdgeGrid Runner', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test.describe('[bru]', () => { + test('Send individual requests', async ({ pageWithUserData: page }) => { + test.setTimeout(3 * 60 * 1000); + await sendAllRequests(page, BRU_COLLECTION); + }); + }); + + test.describe('[yml]', () => { + test('Send individual requests', async ({ pageWithUserData: page }) => { + test.setTimeout(3 * 60 * 1000); + await sendAllRequests(page, YML_COLLECTION); + }); + }); +}); diff --git a/tests/auth/akamai-edgegrid/akamai-edgegrid.spec.ts b/tests/auth/akamai-edgegrid/akamai-edgegrid.spec.ts new file mode 100644 index 000000000..2ddbcaa12 --- /dev/null +++ b/tests/auth/akamai-edgegrid/akamai-edgegrid.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from '../../../playwright'; +import { + closeAllCollections, + closeAllTabs, + createCollection, + createRequest, + getResponseBody, + openRequest, + saveRequest, + selectRequestPaneTab, + sendRequestAndWaitForResponse +} from '../../utils/page'; + +// Shared test creds — the local simulator (packages/bruno-tests/src/auth/edgegrid.js) knows +// this secret, so signed requests validate (200) and the response echoes the effective values. +const { TEST_EDGEGRID } = require('../../../packages/bruno-tests/src/auth/edgegrid'); +const SIMULATOR_URL = 'http://localhost:8081/api/auth/edgegrid/resource'; + +const label = (page, text: string) => page.locator('label').filter({ hasText: text }); +const fieldRow = (page, text: string) => label(page, text).locator('..'); +const editorIn = (row) => row.locator('.single-line-editor-wrapper .CodeMirror'); +const advancedHeader = (page) => page.locator('.advanced-settings-header').filter({ hasText: 'Advanced Settings' }); + +const fieldValue = (page, fieldName: string): Promise => + editorIn(fieldRow(page, fieldName)).evaluate((el: any) => el.CodeMirror.getValue()); + +const expectFieldValue = async (page, fieldName: string, expected: string) => { + await expect.poll(() => fieldValue(page, fieldName)).toBe(expected); +}; + +// Select-all then type so it works for empty fields and the prefilled Base URL alike. +const setField = async (page, fieldName: string, value: string) => { + await editorIn(fieldRow(page, fieldName)).click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.type(value); +}; + +const selectEdgeGridMode = async (page) => { + await page.locator('.auth-mode-label').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Akamai EdgeGrid' }).click(); +}; + +const BASIC_FIELDS = ['Access Token', 'Client Token', 'Client Secret']; +const ADVANCED_FIELDS = ['Base URL', 'Nonce', 'Timestamp', 'Headers to Sign', 'Max Body Size']; + +// Exact values for the configure → save → send → persist round-trip. Base URL is the simulator +// host (so the signature validates), and a fixed nonce/timestamp so we can assert the echo. +const VALUES: Record = { + 'Access Token': TEST_EDGEGRID.accessToken, + 'Client Token': TEST_EDGEGRID.clientToken, + 'Client Secret': TEST_EDGEGRID.clientSecret, + 'Base URL': 'http://localhost:8081', + 'Nonce': 'ui-nonce-123', + 'Timestamp': '20240101T00:00:00+0000', + 'Headers to Sign': 'X-Sign-A', + 'Max Body Size': '4096' +}; + +test.describe('Akamai EdgeGrid Authentication (request level)', () => { + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('Configure, sign against the simulator, and persist all fields', async ({ page, createTmpDir }) => { + test.setTimeout(90_000); + + await createCollection(page, 'edgegrid-ui-test', await createTmpDir()); + await createRequest(page, 'edgegrid-request', 'edgegrid-ui-test', { url: SIMULATOR_URL }); + await openRequest(page, 'edgegrid-ui-test', 'edgegrid-request'); + await selectRequestPaneTab(page, 'Auth'); + await selectEdgeGridMode(page); + + await test.step('Basic fields render after selecting EdgeGrid (regression: request-level render)', async () => { + for (const name of BASIC_FIELDS) { + await expect(label(page, name)).toBeVisible(); + } + await expect(advancedHeader(page)).toBeVisible(); + }); + + await test.step('Base URL editor prefills with the request URL when empty', async () => { + await expectFieldValue(page, 'Base URL', SIMULATOR_URL); + }); + + await test.step('Fill all basic + advanced fields and save', async () => { + for (const name of BASIC_FIELDS) await setField(page, name, VALUES[name]); + for (const name of ADVANCED_FIELDS) await setField(page, name, VALUES[name]); + await saveRequest(page); + }); + + await test.step('Send the request — simulator validates and echoes the configured values', async () => { + await sendRequestAndWaitForResponse(page, 200); + const body = await getResponseBody(page); + expect(body).toContain('ui-nonce-123'); // configured nonce was signed & sent + expect(body).toContain('20240101T00:00:00+0000'); // configured timestamp + expect(body).toContain('localhost:8081'); // base_url host the signature validated against + }); + + await test.step('All fields persist across reopen (regression: draft-path save)', async () => { + await closeAllTabs(page); + await openRequest(page, 'edgegrid-ui-test', 'edgegrid-request'); + await selectRequestPaneTab(page, 'Auth'); + for (const name of BASIC_FIELDS) await expectFieldValue(page, name, VALUES[name]); + for (const name of ADVANCED_FIELDS) await expectFieldValue(page, name, VALUES[name]); + }); + }); + + test('Empty Advanced Settings fall back to auto-generated defaults', async ({ page, createTmpDir }) => { + test.setTimeout(90_000); + + await createCollection(page, 'edgegrid-defaults-test', await createTmpDir()); + await createRequest(page, 'edgegrid-defaults', 'edgegrid-defaults-test', { url: SIMULATOR_URL }); + await openRequest(page, 'edgegrid-defaults-test', 'edgegrid-defaults'); + await selectRequestPaneTab(page, 'Auth'); + await selectEdgeGridMode(page); + + await test.step('Fill only the required credentials, leave Advanced Settings empty', async () => { + await setField(page, 'Access Token', TEST_EDGEGRID.accessToken); + await setField(page, 'Client Token', TEST_EDGEGRID.clientToken); + await setField(page, 'Client Secret', TEST_EDGEGRID.clientSecret); + await saveRequest(page); + }); + + await test.step('Send request — simulator echoes the auto-generated defaults', async () => { + await sendRequestAndWaitForResponse(page, 200); + const body = await getResponseBody(page); + // nonce auto-generated as a UUID v4 + expect(body).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i); + // timestamp auto-generated in EdgeGrid format YYYYMMDDTHH:MM:SS+0000 + expect(body).toMatch(/\d{8}T\d{2}:\d{2}:\d{2}\+0000/); + // base_url defaulted to the request host + expect(body).toContain('localhost:8081'); + // max_body_size defaulted to 131072 + expect(body).toContain('131072'); + }); + }); +}); diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Base URL Override 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Base URL Override 200.bru new file mode 100644 index 000000000..300e724da --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Base URL Override 200.bru @@ -0,0 +1,27 @@ +meta { + name: EdgeGrid Base URL Override 200 + type: http + seq: 6 +} + +get { + url: {{localhost}}/api/auth/edgegrid/resource + body: none + auth: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: http://localhost:8081 + headersToSign: + maxBodySize: +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 200.bru new file mode 100644 index 000000000..3629614f3 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 200.bru @@ -0,0 +1,27 @@ +meta { + name: EdgeGrid GET 200 + type: http + seq: 1 +} + +get { + url: {{localhost}}/api/auth/edgegrid/resource + body: none + auth: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: + headersToSign: + maxBodySize: +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 401.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 401.bru new file mode 100644 index 000000000..6bb7dcd2e --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid GET 401.bru @@ -0,0 +1,26 @@ +meta { + name: EdgeGrid GET 401 + type: http + seq: 2 +} + +get { + url: {{localhost}}/api/auth/edgegrid/resource + body: none + auth: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: this-is-the-wrong-secret= + nonce: + timestamp: + baseURL: + headersToSign: + maxBodySize: +} + +assert { + res.status: eq 401 +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Inherit 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Inherit 200.bru new file mode 100644 index 000000000..cefc9a32b --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Inherit 200.bru @@ -0,0 +1,16 @@ +meta { + name: EdgeGrid Inherit 200 + type: http + seq: 7 +} + +get { + url: {{localhost}}/api/auth/edgegrid/resource + body: none + auth: inherit +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST JSON 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST JSON 200.bru new file mode 100644 index 000000000..2c130bd23 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST JSON 200.bru @@ -0,0 +1,31 @@ +meta { + name: EdgeGrid POST JSON 200 + type: http + seq: 3 +} + +post { + url: {{localhost}}/api/auth/edgegrid/resource + body: json + auth: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: + headersToSign: + maxBodySize: +} + +body:json { + {"name":"bruno","kind":"compact"} +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST Pretty JSON 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST Pretty JSON 200.bru new file mode 100644 index 000000000..04eda60c0 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid POST Pretty JSON 200.bru @@ -0,0 +1,34 @@ +meta { + name: EdgeGrid POST Pretty JSON 200 + type: http + seq: 4 +} + +post { + url: {{localhost}}/api/auth/edgegrid/resource + body: json + auth: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: + headersToSign: + maxBodySize: +} + +body:json { + { + "name": "bruno", + "kind": "pretty" + } +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Signed Headers 200.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Signed Headers 200.bru new file mode 100644 index 000000000..5e53d8205 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/EdgeGrid Signed Headers 200.bru @@ -0,0 +1,32 @@ +meta { + name: EdgeGrid Signed Headers 200 + type: http + seq: 5 +} + +get { + url: {{localhost}}/api/auth/edgegrid/resource + body: none + auth: akamai-edgegrid +} + +headers { + X-Test1: my-signed-value + x-eg-headers-to-sign: X-Test1 +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: + headersToSign: X-Test1 + maxBodySize: +} + +assert { + res.status: eq 200 + res.body.authenticated: eq true +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/bruno.json b/tests/auth/akamai-edgegrid/fixtures/collections/bru/bruno.json new file mode 100644 index 000000000..805e773d2 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "edgegrid-testbench-bru", + "type": "collection" +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/collection.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/collection.bru new file mode 100644 index 000000000..de2225516 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/collection.bru @@ -0,0 +1,14 @@ +auth { + mode: akamai-edgegrid +} + +auth:akamai-edgegrid { + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + nonce: + timestamp: + baseURL: + headersToSign: + maxBodySize: +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/bru/environments/Local.bru b/tests/auth/akamai-edgegrid/fixtures/collections/bru/environments/Local.bru new file mode 100644 index 000000000..5d116f2ef --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/bru/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + localhost: http://localhost:8081 +} diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Base URL Override 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Base URL Override 200.yml new file mode 100644 index 000000000..656511942 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Base URL Override 200.yml @@ -0,0 +1,29 @@ +info: + name: EdgeGrid Base URL Override 200 + type: http + seq: 6 + +http: + method: GET + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + baseURL: http://localhost:8081 + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 200.yml new file mode 100644 index 000000000..a42542587 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 200.yml @@ -0,0 +1,28 @@ +info: + name: EdgeGrid GET 200 + type: http + seq: 1 + +http: + method: GET + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 401.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 401.yml new file mode 100644 index 000000000..6eb23757b --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid GET 401.yml @@ -0,0 +1,25 @@ +info: + name: EdgeGrid GET 401 + type: http + seq: 2 + +http: + method: GET + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: this-is-the-wrong-secret= + +runtime: + assertions: + - expression: res.status + operator: eq + value: "401" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Inherit 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Inherit 200.yml new file mode 100644 index 000000000..16ca0edc7 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Inherit 200.yml @@ -0,0 +1,24 @@ +info: + name: EdgeGrid Inherit 200 + type: http + seq: 7 + +http: + method: GET + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: inherit + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST JSON 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST JSON 200.yml new file mode 100644 index 000000000..efd9cf7a3 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST JSON 200.yml @@ -0,0 +1,32 @@ +info: + name: EdgeGrid POST JSON 200 + type: http + seq: 3 + +http: + method: POST + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + body: + type: json + data: |- + {"name":"bruno","kind":"compact"} + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST Pretty JSON 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST Pretty JSON 200.yml new file mode 100644 index 000000000..03d808005 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid POST Pretty JSON 200.yml @@ -0,0 +1,35 @@ +info: + name: EdgeGrid POST Pretty JSON 200 + type: http + seq: 4 + +http: + method: POST + url: "{{localhost}}/api/auth/edgegrid/resource" + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + body: + type: json + data: |- + { + "name": "bruno", + "kind": "pretty" + } + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Signed Headers 200.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Signed Headers 200.yml new file mode 100644 index 000000000..d48d0bf34 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/EdgeGrid Signed Headers 200.yml @@ -0,0 +1,34 @@ +info: + name: EdgeGrid Signed Headers 200 + type: http + seq: 5 + +http: + method: GET + url: "{{localhost}}/api/auth/edgegrid/resource" + headers: + - name: X-Test1 + value: my-signed-value + - name: x-eg-headers-to-sign + value: X-Test1 + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + headersToSign: X-Test1 + +runtime: + assertions: + - expression: res.status + operator: eq + value: "200" + - expression: res.body.authenticated + operator: eq + value: "true" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/environments/Local.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/environments/Local.yml new file mode 100644 index 000000000..930a0b399 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/environments/Local.yml @@ -0,0 +1,5 @@ +name: Local + +variables: + - name: localhost + value: http://localhost:8081 diff --git a/tests/auth/akamai-edgegrid/fixtures/collections/yml/opencollection.yml b/tests/auth/akamai-edgegrid/fixtures/collections/yml/opencollection.yml new file mode 100644 index 000000000..44452eb19 --- /dev/null +++ b/tests/auth/akamai-edgegrid/fixtures/collections/yml/opencollection.yml @@ -0,0 +1,13 @@ +opencollection: '1.0.0' + +info: + name: edgegrid-testbench-yml + +request: + auth: + type: akamai-edgegrid + accessToken: akab-access-token-bruno-test + clientToken: akab-client-token-bruno-test + clientSecret: dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA== + +bundled: false diff --git a/tests/auth/akamai-edgegrid/init-user-data/preferences.json b/tests/auth/akamai-edgegrid/init-user-data/preferences.json new file mode 100644 index 000000000..a127ece24 --- /dev/null +++ b/tests/auth/akamai-edgegrid/init-user-data/preferences.json @@ -0,0 +1,10 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{collectionPath}}/bru", "{{collectionPath}}/yml"], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/import/postman/fixtures/postman-edgegrid-collection.json b/tests/import/postman/fixtures/postman-edgegrid-collection.json new file mode 100644 index 000000000..59f316b05 --- /dev/null +++ b/tests/import/postman/fixtures/postman-edgegrid-collection.json @@ -0,0 +1,155 @@ +{ + "info": { + "_postman_id": "5dfec9be-beb9-45f2-b5b1-0ecf5c3d3e2b", + "name": "EgdeGrid Auth", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "36465539" + }, + "item": [ + { + "name": "Edgegrid Auth - inherit", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6000/edgegrid/version", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6000", + "path": [ + "edgegrid", + "version" + ] + } + }, + "response": [] + }, + { + "name": "Edgegrid auth - request level", + "request": { + "auth": { + "type": "edgegrid", + "edgegrid": [ + { + "key": "maxBodySize", + "value": 131072, + "type": "number" + }, + { + "key": "baseURL", + "value": "https://akab-h05tnam3wl42son7nktnlnnx-kbob3i3v.luna.akamaiapis.request.net", + "type": "string" + }, + { + "key": "timestamp", + "value": "20160804T07:00:00+0000", + "type": "string" + }, + { + "key": "nonce", + "value": "ec9d20ee-1e9b-4c1f-925a-request", + "type": "string" + }, + { + "key": "clientSecret", + "value": "C113nt53KR3TN6N90yVuAgICxIRwsObLi0E67/request=", + "type": "string" + }, + { + "key": "clientToken", + "value": "akab-c113ntt0k3n4qtari252bfxxbsl-request", + "type": "string" + }, + { + "key": "accessToken", + "value": "akab-acc35t0k3nodujqunph3w7hzp7-request", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:6000/edgegrid/version/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6000", + "path": [ + "edgegrid", + "version", + "2" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "edgegrid", + "edgegrid": [ + { + "key": "maxBodySize", + "value": 131072, + "type": "number" + }, + { + "key": "baseURL", + "value": "https://akab-h05tnam3wl42son7nktnlnnx-kbob3i3v.luna.akamaiapis.net", + "type": "string" + }, + { + "key": "timestamp", + "value": "20160804T07:00:00+0000", + "type": "string" + }, + { + "key": "nonce", + "value": "ec9d20ee-1e9b-4c1f-925a-f0017754f86c", + "type": "string" + }, + { + "key": "clientSecret", + "value": "C113nt53KR3TN6N90yVuAgICxIRwsObLi0E67/N8eRN=", + "type": "string" + }, + { + "key": "clientToken", + "value": "akab-c113ntt0k3n4qtari252bfxxbsl-yvsdj", + "type": "string" + }, + { + "key": "accessToken", + "value": "akab-acc35t0k3nodujqunph3w7hzp7-gtm6ij", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/import/postman/import-edgegrid-collection.spec.ts b/tests/import/postman/import-edgegrid-collection.spec.ts new file mode 100644 index 000000000..3f459582f --- /dev/null +++ b/tests/import/postman/import-edgegrid-collection.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import * as fs from 'fs'; +import { closeAllCollections, openCollection, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +/** + * Imports the real Postman EdgeGrid collection and asserts every credential value lands in the + * right SingleLineEditor at every level (collection settings, request level, inherit). Expected + * values are read straight from the fixture JSON so assertions can never drift from the source. + * + * Locators are scoped to the VISIBLE pane: when the collection-settings tab and a request tab + * are both open, the field labels/editors exist twice in the DOM (one hidden), so we must only + * read the active pane. + */ + +const FIXTURE = path.resolve(__dirname, 'fixtures', 'postman-edgegrid-collection.json'); +const COLLECTION_NAME = 'EgdeGrid Auth'; +const REQUEST_LEVEL = 'Edgegrid auth - request level'; +const INHERIT_REQUEST = 'Edgegrid Auth - inherit'; + +// UI label → Postman/Bruno camelCase key (1:1). Order = display order (3 basic, then advanced). +const FIELDS: Array<{ label: string; key: string; advanced?: boolean }> = [ + { label: 'Access Token', key: 'accessToken' }, + { label: 'Client Token', key: 'clientToken' }, + { label: 'Client Secret', key: 'clientSecret' }, + { label: 'Base URL', key: 'baseURL', advanced: true }, + { label: 'Nonce', key: 'nonce', advanced: true }, + { label: 'Timestamp', key: 'timestamp', advanced: true }, + { label: 'Headers to Sign', key: 'headersToSign', advanced: true }, + { label: 'Max Body Size', key: 'maxBodySize', advanced: true } +]; + +// Flatten a Postman edgegrid auth array ([{key,value}]) into { key: stringValue }. +const egMap = (arr: any[] = []): Record => + Object.fromEntries((arr || []).map((p) => [p.key, p.value == null ? '' : String(p.value)])); + +const pm = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); +const collectionExpected = egMap(pm.auth?.edgegrid); +const requestExpected = egMap(pm.item.find((i: any) => i.name === REQUEST_LEVEL)?.request?.auth?.edgegrid); + +// The exact key/value pairs the fixture is expected to carry. Asserting the parsed maps equal +// these (below) guarantees the UI value checks are matching against real, exact values — not +// silently degenerating to '' === '' if the fixture/mapping ever changes. +const EXPECTED_COLLECTION = { + accessToken: 'akab-acc35t0k3nodujqunph3w7hzp7-gtm6ij', + clientToken: 'akab-c113ntt0k3n4qtari252bfxxbsl-yvsdj', + clientSecret: 'C113nt53KR3TN6N90yVuAgICxIRwsObLi0E67/N8eRN=', + nonce: 'ec9d20ee-1e9b-4c1f-925a-f0017754f86c', + timestamp: '20160804T07:00:00+0000', + baseURL: 'https://akab-h05tnam3wl42son7nktnlnnx-kbob3i3v.luna.akamaiapis.net', + maxBodySize: '131072' +}; +const EXPECTED_REQUEST = { + accessToken: 'akab-acc35t0k3nodujqunph3w7hzp7-request', + clientToken: 'akab-c113ntt0k3n4qtari252bfxxbsl-request', + clientSecret: 'C113nt53KR3TN6N90yVuAgICxIRwsObLi0E67/request=', + nonce: 'ec9d20ee-1e9b-4c1f-925a-request', + timestamp: '20160804T07:00:00+0000', + baseURL: 'https://akab-h05tnam3wl42son7nktnlnnx-kbob3i3v.luna.akamaiapis.request.net', + maxBodySize: '131072' +}; + +// --- visible-scoped locators (active pane only) --- +const visible = (loc) => loc.filter({ visible: true }); +const visibleLabel = (page, labelText: string) => visible(page.locator('label').filter({ hasText: labelText })); +const visibleAuthModeLabel = (page) => visible(page.locator('.auth-mode-label')); + +const fieldEditorValue = (page, labelText: string): Promise => + visibleLabel(page, labelText) + .locator('..') + .locator('.single-line-editor-wrapper .CodeMirror') + .evaluate((el: any) => el.CodeMirror.getValue()); + +// Assert mode = EdgeGrid and EVERY field's editor value matches the expected JSON value. +const assertEdgeGridValues = async (page, expected: Record) => { + await expect(visibleAuthModeLabel(page)).toHaveText(/Akamai EdgeGrid/); + + // Advanced Settings is always open, so all fields are visible — assert them all. + for (const f of FIELDS) { + await expect.poll(() => fieldEditorValue(page, f.label), { message: `${f.label} (${f.key})` }).toBe( + expected[f.key] ?? '' + ); + } +}; + +test.describe('Import Postman Collection with Akamai EdgeGrid auth', () => { + let originalShowOpenDialog; + + test.beforeAll(async ({ electronApp }) => { + await electronApp.evaluate(({ dialog }) => { + originalShowOpenDialog = dialog.showOpenDialog; + }); + // Pin the fixture to the exact key/value pairs we assert in the UI (exact, not a count). + expect(collectionExpected).toEqual(EXPECTED_COLLECTION); + expect(requestExpected).toEqual(EXPECTED_REQUEST); + }); + + test.afterAll(async ({ electronApp, page }) => { + await closeAllCollections(page); + await electronApp.evaluate(({ dialog }) => { + dialog.showOpenDialog = originalShowOpenDialog; + }); + }); + + test('imports EdgeGrid auth with exact values at collection, request and inherit levels', async ({ + page, + electronApp, + createTmpDir + }) => { + test.setTimeout(120_000); + const locators = buildCommonLocators(page); + const importDir = await createTmpDir('imported-edgegrid'); + + await electronApp.evaluate(({ dialog }, { importDir }) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importDir] }); + }, { importDir }); + + await test.step('Import the Postman collection', async () => { + await locators.plusMenu.button().click(); + await locators.plusMenu.importCollection().click(); + await page.getByRole('dialog').waitFor({ state: 'visible' }); + await locators.import.fileInput().setInputFiles(FIXTURE); + await locators.import.locationModal().waitFor({ state: 'visible', timeout: 10000 }); + + const hasError = await locators.import.parsingError().isVisible().catch(() => false); + expect(hasError).toBeFalsy(); + + await expect(locators.import.locationModal().getByText(COLLECTION_NAME)).toBeVisible(); + await locators.import.browseLink(locators.import.locationModal()).click(); + const locationModal = locators.import.locationModal(); + await locators.import.importButton(locationModal).click(); + await locationModal.waitFor({ state: 'hidden' }); + }); + + await openCollection(page, COLLECTION_NAME); + + await test.step('Collection-level: every EdgeGrid field value matches the JSON', async () => { + await locators.actions.collectionActions(COLLECTION_NAME).click(); + await locators.dropdown.item('Settings').click(); + await page.getByTestId('collection-settings-tab-auth').click(); + await assertEdgeGridValues(page, collectionExpected); + }); + + await test.step('Request-level ("Edgegrid auth - request level"): every field value matches the JSON', async () => { + await locators.sidebar.request(REQUEST_LEVEL).click(); + await selectRequestPaneTab(page, 'Auth'); + await assertEdgeGridValues(page, requestExpected); + }); + + await test.step('Inherit request inherits EdgeGrid from the collection', async () => { + await locators.sidebar.request(INHERIT_REQUEST).click(); + await selectRequestPaneTab(page, 'Auth'); + await expect(visibleAuthModeLabel(page)).toHaveText(/Inherit/); + await expect(visible(page.locator('.inherit-mode-text'))).toHaveText('Akamai EdgeGrid'); + }); + }); +});