mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-16 04:11:29 +00:00
feat: support annotations for secret environment variables in bru and preserve variable value type in yml (#8251)
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import reducer, { collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
|
||||
const COLLECTION_UID = 'col-1';
|
||||
const ENV_UID = 'env-1';
|
||||
|
||||
const externalSecrets = {
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'secret', value: 'secret/data/secret' },
|
||||
{ name: 'password', value: 'secret/data/password' }
|
||||
]
|
||||
};
|
||||
|
||||
const makeEnvironment = (overrides = {}) => ({
|
||||
uid: ENV_UID,
|
||||
name: 'test_env',
|
||||
pathname: '/coll/environments/test_env.bru',
|
||||
variables: [
|
||||
{ uid: 'var-1', name: 'env_str', value: 'env_string', type: 'text', enabled: true, secret: false },
|
||||
{ uid: 'var-2', name: 'env_num', value: '300', type: 'text', datatype: 'number', enabled: true, secret: false },
|
||||
{ uid: 'var-3', name: 'env_bool', value: 'true', type: 'text', datatype: 'boolean', enabled: true, secret: false },
|
||||
{ uid: 'var-4', name: 'env_obj', value: '{"scope":"env"}', type: 'text', datatype: 'object', enabled: true, secret: false }
|
||||
],
|
||||
color: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const makeInitialState = (environments = []) => ({
|
||||
collections: [
|
||||
{
|
||||
uid: COLLECTION_UID,
|
||||
pathname: '/coll',
|
||||
items: [],
|
||||
environments
|
||||
}
|
||||
],
|
||||
collectionSortOrder: 'default',
|
||||
activeWorkspaceUid: null
|
||||
});
|
||||
|
||||
describe('collectionAddEnvFileEvent', () => {
|
||||
it('keeps externalSecrets when a new environment is added', () => {
|
||||
const state = makeInitialState();
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('keeps externalSecrets when an existing environment changes', () => {
|
||||
const state = makeInitialState([makeEnvironment()]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('keeps variable datatype when a new environment is added', () => {
|
||||
const state = makeInitialState();
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
const variables = nextState.collections[0].environments[0].variables;
|
||||
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(variables.find((v) => v.name === 'env_bool')).toMatchObject({ value: 'true', datatype: 'boolean' });
|
||||
expect(variables.find((v) => v.name === 'env_obj')).toMatchObject({ value: '{"scope":"env"}', datatype: 'object' });
|
||||
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('keeps variable datatype when an existing environment changes', () => {
|
||||
const state = makeInitialState([makeEnvironment({ variables: [] })]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
const variables = nextState.collections[0].environments[0].variables;
|
||||
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('clears externalSecrets when the block is removed from the file', () => {
|
||||
const state = makeInitialState([makeEnvironment({ externalSecrets })]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2067,11 +2067,13 @@ export const collectionsSlice = createSlice({
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.vars = item.draft.request.vars || {};
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2421,11 +2423,13 @@ export const collectionsSlice = createSlice({
|
||||
if (!folder.draft) {
|
||||
folder.draft = cloneDeep(folder.root);
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2659,11 +2663,13 @@ export const collectionsSlice = createSlice({
|
||||
root: cloneDeep(collection.root)
|
||||
};
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2912,6 +2918,7 @@ export const collectionsSlice = createSlice({
|
||||
existingEnv.pathname = environment.pathname;
|
||||
existingEnv.variables = environment.variables;
|
||||
existingEnv.color = environment.color;
|
||||
existingEnv.externalSecrets = environment.externalSecrets;
|
||||
/*
|
||||
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Variable } from '@opencollection/types/common/variables';
|
||||
import { Variable, VariableTypedValue } from '@opencollection/types/common/variables';
|
||||
import { FolderRequest as BrunoFolderRequest } from '@usebruno/schema-types/collection/folder';
|
||||
import { Variable as BrunoVariable, Variables as BrunoVariables } from '@usebruno/schema-types/common/variables';
|
||||
import { uuid, ensureString } from '../../../utils';
|
||||
|
||||
export const isTypedValue = (value: unknown): value is VariableTypedValue => {
|
||||
return (
|
||||
typeof value === 'object'
|
||||
&& value !== null
|
||||
&& !Array.isArray(value)
|
||||
&& 'type' in value
|
||||
&& 'data' in value
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Bruno pre-request variables to OpenCollection variables format.
|
||||
* Note: Post-response variables are now converted to actions (see actions.ts).
|
||||
@@ -21,7 +31,10 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars']
|
||||
const ocVariables: Variable[] = reqVarsArray.map((v: BrunoVariable): Variable => {
|
||||
const variable: Variable = {
|
||||
name: v.name || '',
|
||||
value: v.value || ''
|
||||
value:
|
||||
v.datatype && v.datatype !== 'string'
|
||||
? { type: v.datatype, data: ensureString(v.value) }
|
||||
: v.value || ''
|
||||
};
|
||||
|
||||
if (v?.description?.trim().length) {
|
||||
@@ -52,11 +65,20 @@ export const toBrunoVariables = (variables: Variable[] | null | undefined): { re
|
||||
const variable: BrunoVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: ensureString(v.value),
|
||||
value: '',
|
||||
enabled: v.disabled !== true,
|
||||
local: false
|
||||
};
|
||||
|
||||
if (isTypedValue(v.value)) {
|
||||
variable.value = ensureString(v.value.data);
|
||||
if (v.value.type !== 'string' && v.value.type !== 'null') {
|
||||
variable.datatype = v.value.type;
|
||||
}
|
||||
} else {
|
||||
variable.value = ensureString(v.value);
|
||||
}
|
||||
|
||||
if (v.description) {
|
||||
variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Environment } from '@opencollection/types/config/environments';
|
||||
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
|
||||
import { parseYml } from './utils';
|
||||
import { uuid, ensureString } from '../../utils';
|
||||
import { isTypedValue } from './common/variables';
|
||||
|
||||
const isSecretVariable = (v: Variable | SecretVariable): v is SecretVariable => {
|
||||
return 'secret' in v && v.secret === true;
|
||||
@@ -15,7 +16,7 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
|
||||
|
||||
return variables.map((v): BrunoEnvironmentVariable => {
|
||||
if (isSecretVariable(v)) {
|
||||
return {
|
||||
const variable: BrunoEnvironmentVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: '',
|
||||
@@ -23,19 +24,55 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
|
||||
enabled: v.disabled !== true,
|
||||
secret: true
|
||||
};
|
||||
|
||||
if (v.type && v.type !== 'string' && v.type !== 'null') {
|
||||
variable.datatype = v.type;
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
const variable: BrunoEnvironmentVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: ensureString(v.value),
|
||||
value: '',
|
||||
type: 'text',
|
||||
enabled: v.disabled !== true,
|
||||
secret: false
|
||||
};
|
||||
|
||||
if (isTypedValue(v.value)) {
|
||||
variable.value = ensureString(v.value.data);
|
||||
if (v.value.type !== 'string' && v.value.type !== 'null') {
|
||||
variable.datatype = v.value.type;
|
||||
}
|
||||
} else {
|
||||
variable.value = ensureString(v.value);
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
};
|
||||
|
||||
const toBrunoExternalSecrets = (externalSecrets: any): BrunoEnvironment['externalSecrets'] | undefined => {
|
||||
if (!externalSecrets || typeof externalSecrets !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variables = Array.isArray(externalSecrets.variables)
|
||||
? externalSecrets.variables.map((variable: any) => {
|
||||
const result: Record<string, string> = { name: ensureString(variable?.name) };
|
||||
Object.keys(variable || {}).forEach((key) => {
|
||||
if (key !== 'name') {
|
||||
result[key] = ensureString(variable[key]);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
})
|
||||
: [];
|
||||
|
||||
return { type: ensureString(externalSecrets.type), variables } as BrunoEnvironment['externalSecrets'];
|
||||
};
|
||||
|
||||
const parseEnvironment = (ymlString: string): BrunoEnvironment => {
|
||||
try {
|
||||
const ocEnvironment: Environment = parseYml(ymlString);
|
||||
@@ -47,6 +84,11 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => {
|
||||
color: ocEnvironment.color || null
|
||||
};
|
||||
|
||||
const externalSecrets = toBrunoExternalSecrets((ocEnvironment as any).externalSecrets);
|
||||
if (externalSecrets) {
|
||||
brunoEnvironment.externalSecrets = externalSecrets;
|
||||
}
|
||||
|
||||
return brunoEnvironment;
|
||||
} catch (error) {
|
||||
console.error('Error parsing environment:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Environment as BrunoEnvironment, EnvironmentVariable as BrunoEnvir
|
||||
import type { Environment } from '@opencollection/types/config/environments';
|
||||
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
|
||||
import { stringifyYml } from './utils';
|
||||
import { ensureString } from '../../utils';
|
||||
|
||||
const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariable[]): (Variable | SecretVariable)[] | undefined => {
|
||||
if (!variables?.length) {
|
||||
@@ -20,6 +21,9 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl
|
||||
secret: true,
|
||||
name: v.name || ''
|
||||
};
|
||||
if (v.datatype && v.datatype !== 'string') {
|
||||
secretVar.type = v.datatype;
|
||||
}
|
||||
if (v.enabled === false) {
|
||||
secretVar.disabled = true;
|
||||
}
|
||||
@@ -28,7 +32,10 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl
|
||||
|
||||
const variable: Variable = {
|
||||
name: v.name || '',
|
||||
value: v.value as string
|
||||
value:
|
||||
v.datatype && v.datatype !== 'string'
|
||||
? { type: v.datatype, data: ensureString(v.value) }
|
||||
: ensureString(v.value)
|
||||
};
|
||||
|
||||
if (v.enabled === false) {
|
||||
@@ -58,6 +65,13 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => {
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.externalSecrets) {
|
||||
(ocEnvironment as any).externalSecrets = {
|
||||
type: environment.externalSecrets.type,
|
||||
variables: (environment.externalSecrets.variables || []).map((variable) => ({ ...variable }))
|
||||
};
|
||||
}
|
||||
|
||||
return stringifyYml(ocEnvironment);
|
||||
} catch (error) {
|
||||
console.error('Error stringifying environment:', error);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import parseEnvironment from '../parseEnvironment';
|
||||
import stringifyEnvironment from '../stringifyEnvironment';
|
||||
|
||||
const ENV_YML = `name: test_env
|
||||
variables:
|
||||
- name: env_str
|
||||
value: env_string
|
||||
- name: env_num
|
||||
value:
|
||||
type: number
|
||||
data: "300"
|
||||
- name: env_bool
|
||||
value:
|
||||
type: boolean
|
||||
data: "true"
|
||||
- name: env_obj
|
||||
value:
|
||||
type: object
|
||||
data: |-
|
||||
{
|
||||
"scope": "env"
|
||||
}
|
||||
- name: falsy_num
|
||||
value:
|
||||
type: number
|
||||
data: "0"
|
||||
- name: falsy_bool
|
||||
value:
|
||||
type: boolean
|
||||
data: "false"
|
||||
- secret: true
|
||||
name: env_secret_str
|
||||
- secret: true
|
||||
name: env_secret_num
|
||||
type: number
|
||||
- secret: true
|
||||
name: env_secret_bool
|
||||
type: boolean
|
||||
- secret: true
|
||||
name: env_secret_obj
|
||||
type: object
|
||||
- name: env_array_obj
|
||||
value:
|
||||
type: object
|
||||
data: "[1,2,3,4]"
|
||||
`;
|
||||
|
||||
const byName = (env) => Object.fromEntries(env.variables.map((v) => [v.name, v]));
|
||||
|
||||
describe('yml parseEnvironment - typed values', () => {
|
||||
it('keeps the value as a string and preserves the type via datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_num).toMatchObject({ value: '300', type: 'text', datatype: 'number' });
|
||||
expect(typeof variables.env_num.value).toBe('string');
|
||||
|
||||
expect(variables.env_bool).toMatchObject({ value: 'true', datatype: 'boolean' });
|
||||
expect(variables.falsy_num).toMatchObject({ value: '0', datatype: 'number' });
|
||||
expect(variables.falsy_bool).toMatchObject({ value: 'false', datatype: 'boolean' });
|
||||
|
||||
expect(variables.env_obj.datatype).toBe('object');
|
||||
expect(typeof variables.env_obj.value).toBe('string');
|
||||
expect(variables.env_obj.value).toContain('"scope": "env"');
|
||||
|
||||
expect(variables.env_array_obj).toMatchObject({ value: '[1,2,3,4]', datatype: 'object' });
|
||||
});
|
||||
|
||||
it('does not set datatype for plain string values', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_str).toMatchObject({ value: 'env_string', type: 'text', secret: false });
|
||||
expect(variables.env_str).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('parses secret variables with no value or datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_secret_str).toMatchObject({ name: 'env_secret_str', value: '', secret: true });
|
||||
expect(variables.env_secret_str).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('parses secret variables with a type, keeping the value empty and the type in datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_secret_num).toMatchObject({ value: '', secret: true, datatype: 'number' });
|
||||
expect(variables.env_secret_bool).toMatchObject({ value: '', secret: true, datatype: 'boolean' });
|
||||
expect(variables.env_secret_obj).toMatchObject({ value: '', secret: true, datatype: 'object' });
|
||||
});
|
||||
|
||||
it('serializes secret variable datatype back to a type field, omitting it for plain secrets', () => {
|
||||
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_num\n type: number');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_bool\n type: boolean');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_obj\n type: object');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_str');
|
||||
expect(yml).not.toContain('name: env_secret_str\n type:');
|
||||
});
|
||||
|
||||
it('serializes datatype back to an OpenCollection { type, data } value', () => {
|
||||
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(yml).toContain('type: number');
|
||||
expect(yml).toContain('data: "300"');
|
||||
expect(yml).toContain('type: boolean');
|
||||
expect(yml).toContain('type: object');
|
||||
// plain strings stay plain, never wrapped as a string datatype
|
||||
expect(yml).toContain('value: env_string');
|
||||
expect(yml).not.toContain('type: string');
|
||||
});
|
||||
|
||||
it('round-trips value and datatype through parse -> stringify -> parse', () => {
|
||||
const env = parseEnvironment(ENV_YML);
|
||||
const reparsed = parseEnvironment(stringifyEnvironment(env));
|
||||
|
||||
const withoutUid = (e) => e.variables.map(({ uid, ...rest }) => rest);
|
||||
expect(withoutUid(reparsed)).toEqual(withoutUid(env));
|
||||
});
|
||||
});
|
||||
|
||||
const EXTERNAL_SECRETS_YML = `name: test_env
|
||||
variables:
|
||||
- name: env_str
|
||||
value: env_string
|
||||
externalSecrets:
|
||||
type: my-vault
|
||||
variables:
|
||||
- name: by_path
|
||||
path: secret/data/secret
|
||||
- name: by_secret_name
|
||||
secretName: secret
|
||||
- name: by_vault_name
|
||||
vaultName: secret
|
||||
`;
|
||||
|
||||
describe('yml parseEnvironment - external secrets', () => {
|
||||
it('parses externalSecrets, preserving the type and arbitrary variable keys', () => {
|
||||
const env = parseEnvironment(EXTERNAL_SECRETS_YML);
|
||||
|
||||
expect(env.externalSecrets).toEqual({
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'by_path', path: 'secret/data/secret' },
|
||||
{ name: 'by_secret_name', secretName: 'secret' },
|
||||
{ name: 'by_vault_name', vaultName: 'secret' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set externalSecrets when the yml has none', () => {
|
||||
const env = parseEnvironment('name: test_env\nvariables: []\n');
|
||||
expect(env.externalSecrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips externalSecrets through parse -> stringify -> parse', () => {
|
||||
const env = parseEnvironment(EXTERNAL_SECRETS_YML);
|
||||
const reparsed = parseEnvironment(stringifyEnvironment(env));
|
||||
|
||||
expect(reparsed.externalSecrets).toEqual(env.externalSecrets);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { toBrunoVariables, toOpenCollectionVariables } from '../common/variables';
|
||||
|
||||
describe('yml variables - typed values (collection / folder / request vars)', () => {
|
||||
it('reads a typed value keeping the value as a string and the type in datatype', () => {
|
||||
const ocVariables = [
|
||||
{ name: 'var_str', value: 'plain' },
|
||||
{ name: 'var_num', value: { type: 'number', data: '300' } },
|
||||
{ name: 'var_bool', value: { type: 'boolean', data: 'false' } },
|
||||
{ name: 'var_obj', value: { type: 'object', data: '{"scope":"folder"}' } }
|
||||
];
|
||||
|
||||
const { req } = toBrunoVariables(ocVariables);
|
||||
|
||||
expect(req.find((v) => v.name === 'var_str')).toMatchObject({ value: 'plain' });
|
||||
expect(req.find((v) => v.name === 'var_str')).not.toHaveProperty('datatype');
|
||||
expect(req.find((v) => v.name === 'var_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(req.find((v) => v.name === 'var_bool')).toMatchObject({ value: 'false', datatype: 'boolean' });
|
||||
expect(req.find((v) => v.name === 'var_obj')).toMatchObject({ value: '{"scope":"folder"}', datatype: 'object' });
|
||||
});
|
||||
|
||||
it('writes datatype back as a { type, data } value, leaving plain strings untouched', () => {
|
||||
const brunoVariables = [
|
||||
{ uid: 'u1', name: 'var_str', value: 'plain', enabled: true, local: false },
|
||||
{ uid: 'u2', name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false }
|
||||
];
|
||||
|
||||
const ocVariables = toOpenCollectionVariables(brunoVariables);
|
||||
|
||||
expect(ocVariables).toEqual([
|
||||
{ name: 'var_str', value: 'plain' },
|
||||
{ name: 'var_num', value: { type: 'number', data: '300' } }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const ANNOTATIONS_KEY = Symbol('annotations');
|
||||
// }
|
||||
const indentLevel = 4;
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruEnvFile = (vars | secretvars | color)*
|
||||
BruEnvFile = (vars | secretvars | externalsecrets | color)*
|
||||
|
||||
nl = "\\r"? "\\n"
|
||||
st = " " | "\\t"
|
||||
@@ -59,10 +59,13 @@ const grammar = ohm.grammar(`Bru {
|
||||
// Array Blocks
|
||||
array = st* "[" stnl* valuelist stnl* "]"
|
||||
valuelist = stnl* arrayvalue stnl* ("," stnl* arrayvalue)*
|
||||
arrayvalue = arrayvaluechar*
|
||||
arrayvalue = pairannotations st* arrayvaluechar*
|
||||
arrayvaluechar = ~(nl | st | "[" | "]" | ",") any
|
||||
|
||||
secretvars = "vars:secret" array
|
||||
externalsecrets = "vars:externalsecrets:" externalsecretsname dictionary
|
||||
externalsecretsname = externalsecretsnamechar+
|
||||
externalsecretsnamechar = ~(st | nl | "{") any
|
||||
vars = "vars" dictionary
|
||||
color = "color:" any*
|
||||
}`);
|
||||
@@ -91,25 +94,25 @@ const mapPairListToKeyValPairs = (pairList = []) => {
|
||||
};
|
||||
|
||||
const mapArrayListToKeyValPairs = (arrayList = []) => {
|
||||
arrayList = arrayList.filter((v) => v && v.length);
|
||||
arrayList = arrayList.filter((item) => item && item.name && item.name.length);
|
||||
|
||||
if (!arrayList.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.map(arrayList, (value) => {
|
||||
let name = value;
|
||||
return _.map(arrayList, (item) => {
|
||||
let name = item.name;
|
||||
let enabled = true;
|
||||
if (name && name.length && name.charAt(0) === '~') {
|
||||
name = name.slice(1);
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
value: '',
|
||||
enabled
|
||||
};
|
||||
const result = { name, value: '', enabled };
|
||||
if (item.annotations && item.annotations.length) {
|
||||
result.annotations = item.annotations;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -138,8 +141,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
array(_1, _2, _3, valuelist, _4, _5) {
|
||||
return valuelist.ast;
|
||||
},
|
||||
arrayvalue(chars) {
|
||||
return chars.sourceString ? chars.sourceString.trim() : '';
|
||||
arrayvalue(annotations, _st, chars) {
|
||||
const result = { name: chars.sourceString ? chars.sourceString.trim() : '' };
|
||||
const annotationList = annotations.ast;
|
||||
if (annotationList && annotationList.length > 0) {
|
||||
result.annotations = annotationList;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
valuelist(_1, value, _2, _3, _4, rest) {
|
||||
return [value.ast, ...rest.ast];
|
||||
@@ -261,6 +269,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
variables: vars
|
||||
};
|
||||
},
|
||||
externalsecrets(_1, name, dictionary) {
|
||||
const variables = mapPairListToKeyValPairs(dictionary.ast).map((pair) => ({
|
||||
name: pair.name,
|
||||
value: pair.value
|
||||
}));
|
||||
return {
|
||||
externalSecrets: {
|
||||
type: name.ast,
|
||||
variables
|
||||
}
|
||||
};
|
||||
},
|
||||
externalsecretsname(chars) {
|
||||
return chars.sourceString;
|
||||
},
|
||||
color: (_1, anystring) => {
|
||||
return {
|
||||
color: anystring.sourceString.trim()
|
||||
|
||||
@@ -3,6 +3,7 @@ const { getValueString, indentString, serializeAnnotations } = require('./utils'
|
||||
|
||||
const envToJson = (json) => {
|
||||
const variables = _.get(json, 'variables', []);
|
||||
const externalSecrets = _.get(json, 'externalSecrets', null);
|
||||
const color = _.get(json, 'color', null);
|
||||
|
||||
const vars = variables
|
||||
@@ -17,9 +18,9 @@ const envToJson = (json) => {
|
||||
const secretVars = variables
|
||||
.filter((variable) => variable.secret)
|
||||
.map((variable) => {
|
||||
const { name, enabled } = variable;
|
||||
const { name, enabled, annotations } = variable;
|
||||
const prefix = enabled ? '' : '~';
|
||||
return indentString(`${prefix}${name}`);
|
||||
return indentString(`${serializeAnnotations(annotations)}${prefix}${name}`);
|
||||
});
|
||||
|
||||
let output = '';
|
||||
@@ -43,6 +44,18 @@ ${secretVars.join(',\n')}
|
||||
]
|
||||
`;
|
||||
}
|
||||
|
||||
if (externalSecrets && externalSecrets.type) {
|
||||
const serializedVariables = (externalSecrets.variables || []).map(({ name, value }) =>
|
||||
indentString(`${name}: ${getValueString(value)}`)
|
||||
);
|
||||
|
||||
output += `vars:externalsecrets:${externalSecrets.type} {
|
||||
${serializedVariables.join('\n')}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (color) {
|
||||
output += `color: ${color}
|
||||
`;
|
||||
|
||||
@@ -880,13 +880,82 @@ describe('env pair annotations', () => {
|
||||
expect(output.variables[0]).toEqual({ name: 'API_KEY', value: 'abc123', enabled: true, secret: false });
|
||||
});
|
||||
|
||||
it('secret vars are unaffected by annotation support', () => {
|
||||
it('multiple annotations on a secret var', () => {
|
||||
const input = `vars:secret [
|
||||
SECRET_KEY
|
||||
@string
|
||||
@description('api token')
|
||||
API_TOKEN
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([{ name: 'SECRET_KEY', value: '', enabled: true, secret: true }]);
|
||||
expect(output.variables[0].annotations).toEqual([{ name: 'string' }, { name: 'description', value: 'api token' }]);
|
||||
});
|
||||
|
||||
it('annotations on multiple secret vars', () => {
|
||||
const input = `vars:secret [
|
||||
env_secret_str,
|
||||
@number
|
||||
env_secret_num,
|
||||
@object
|
||||
env_secret_obj,
|
||||
@boolean
|
||||
env_secret_boolean,
|
||||
env_secret_new
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([
|
||||
{ name: 'env_secret_str', value: '', enabled: true, secret: true },
|
||||
{ name: 'env_secret_num', value: '', enabled: true, secret: true, annotations: [{ name: 'number' }] },
|
||||
{ name: 'env_secret_obj', value: '', enabled: true, secret: true, annotations: [{ name: 'object' }] },
|
||||
{ name: 'env_secret_boolean', value: '', enabled: true, secret: true, annotations: [{ name: 'boolean' }] },
|
||||
{ name: 'env_secret_new', value: '', enabled: true, secret: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - multiple secret vars with annotations', () => {
|
||||
const input = `vars:secret [
|
||||
env_secret_str,
|
||||
@number
|
||||
env_secret_num,
|
||||
@object
|
||||
env_secret_obj,
|
||||
@boolean
|
||||
env_secret_boolean,
|
||||
env_secret_new
|
||||
]
|
||||
`;
|
||||
const parsed = envParser(input);
|
||||
expect(jsonToEnv(parsed)).toEqual(input);
|
||||
});
|
||||
|
||||
it('disabled secret var with annotation', () => {
|
||||
const input = `vars:secret [
|
||||
@deprecated
|
||||
~OLD_SECRET
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([
|
||||
{ name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToEnv — disabled secret var with annotation', () => {
|
||||
const json = {
|
||||
variables: [{ name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] }]
|
||||
};
|
||||
const bru = jsonToEnv(json);
|
||||
expect(bru).toContain('@deprecated\n ~OLD_SECRET');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - secret env vars', () => {
|
||||
const input = {
|
||||
variables: [{ name: 'SECRET_KEY', value: '', enabled: true, secret: true, annotations: [{ name: 'description', value: 'my secret key' }] }]
|
||||
};
|
||||
const bru = jsonToEnv(input);
|
||||
const output = envParser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToEnv — annotation without value', () => {
|
||||
@@ -942,6 +1011,44 @@ describe('env pair annotations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('env external secrets', () => {
|
||||
it('parses an external secrets block into { type, variables }', () => {
|
||||
const input = `vars:externalsecrets:my-vault {
|
||||
secret: secret/data/secret
|
||||
password: secret/data/password
|
||||
}
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.externalSecrets).toEqual({
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'secret', value: 'secret/data/secret' },
|
||||
{ name: 'password', value: 'secret/data/password' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an external secrets block with no variables', () => {
|
||||
const input = `vars:externalsecrets:my-vault {
|
||||
}
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.externalSecrets).toEqual({ type: 'my-vault', variables: [] });
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - external secrets', () => {
|
||||
const input = `vars {
|
||||
}
|
||||
vars:externalsecrets:my-vault {
|
||||
secret: secret/data/secret
|
||||
password: secret/data/password
|
||||
}
|
||||
`;
|
||||
const parsed = envParser(input);
|
||||
expect(jsonToEnv(parsed)).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collection pair annotations', () => {
|
||||
it('above-line annotation on a header (collection)', () => {
|
||||
const input = `headers {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UID, Annotation } from '../common';
|
||||
|
||||
export type EnvironmentVariableDatatype = 'string' | 'number' | 'boolean' | 'object';
|
||||
|
||||
export interface EnvironmentVariable {
|
||||
uid: UID;
|
||||
name?: string | null;
|
||||
@@ -7,13 +9,25 @@ export interface EnvironmentVariable {
|
||||
type: 'text';
|
||||
enabled?: boolean;
|
||||
secret?: boolean;
|
||||
datatype?: EnvironmentVariableDatatype;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export interface ExternalSecretVariables {
|
||||
name: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface ExternalSecrets {
|
||||
type: string;
|
||||
variables: ExternalSecretVariables[];
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
uid: UID;
|
||||
name: string;
|
||||
variables: EnvironmentVariable[];
|
||||
externalSecrets?: ExternalSecrets;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export type VariableDatatype = 'string' | 'number' | 'boolean' | 'object';
|
||||
|
||||
/**
|
||||
* Request-scoped variable entry.
|
||||
*/
|
||||
@@ -11,6 +13,7 @@ export interface Variable {
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
local?: boolean;
|
||||
datatype?: VariableDatatype;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const { uuid } = require('../utils/testUtils');
|
||||
const { environmentSchema } = require('./index');
|
||||
|
||||
const buildVariable = (overrides = {}) => ({
|
||||
uid: uuid(),
|
||||
name: 'env_var',
|
||||
value: 'value',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const buildEnvironment = (overrides = {}) => ({
|
||||
uid: uuid(),
|
||||
name: 'My Environment',
|
||||
variables: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('Environment Schema Validation', () => {
|
||||
describe('variable datatype', () => {
|
||||
it.each(['string', 'number', 'boolean', 'object'])('validates a variable with datatype %s', async (datatype) => {
|
||||
const env = buildEnvironment({ variables: [buildVariable({ datatype })] });
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves datatype after validation', async () => {
|
||||
const env = buildEnvironment({ variables: [buildVariable({ value: '300', datatype: 'number' })] });
|
||||
|
||||
const validated = await environmentSchema.validate(env);
|
||||
|
||||
expect(validated.variables[0].datatype).toBe('number');
|
||||
expect(validated.variables[0].value).toBe('300');
|
||||
});
|
||||
});
|
||||
|
||||
describe('external secrets', () => {
|
||||
it('preserves externalSecrets with provider-specific variable keys after validation', async () => {
|
||||
const externalSecrets = {
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'by_value', value: 'secret/data/secret' },
|
||||
{ name: 'by_path', path: 'secret/data/secret' },
|
||||
{ name: 'by_secret_name', secretName: 'secret' },
|
||||
{ name: 'by_vault_name', vaultName: 'secret' }
|
||||
]
|
||||
};
|
||||
const env = buildEnvironment({ externalSecrets });
|
||||
|
||||
const validated = await environmentSchema.validate(env);
|
||||
|
||||
expect(validated.externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('validates externalSecrets with no variables', async () => {
|
||||
const env = buildEnvironment({ externalSecrets: { type: 'my-vault', variables: [] } });
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects unknown keys on the externalSecrets object', async () => {
|
||||
const env = buildEnvironment({
|
||||
externalSecrets: { type: 'my-vault', variables: [], provider: 'hashicorp' }
|
||||
});
|
||||
|
||||
await expect(environmentSchema.validate(env)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,16 +18,31 @@ const environmentVariablesSchema = Yup.object({
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
|
||||
enabled: Yup.boolean().defined(),
|
||||
secret: Yup.boolean()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
// External secret variables carry `name` plus a provider-specific reference key
|
||||
// (path / vaultName / secretName / ...), so unknown keys are allowed through.
|
||||
const externalSecretVariableSchema = Yup.object({
|
||||
name: Yup.string().nullable()
|
||||
}).strict();
|
||||
|
||||
const externalSecretsSchema = Yup.object({
|
||||
type: Yup.string().nullable(),
|
||||
variables: Yup.array().of(externalSecretVariableSchema)
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const environmentSchema = Yup.object({
|
||||
uid: uidSchema,
|
||||
name: Yup.string().min(1).required('name is required'),
|
||||
variables: Yup.array().of(environmentVariablesSchema).required('variables are required'),
|
||||
externalSecrets: externalSecretsSchema.nullable().optional(),
|
||||
color: Yup.string().nullable().optional(),
|
||||
pathname: Yup.string().nullable()
|
||||
})
|
||||
@@ -96,6 +111,7 @@ const varsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
|
||||
// Optional annotations on variables
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
|
||||
@@ -18,6 +18,33 @@ describe('Request Schema Validation', () => {
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - vars with datatype', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: 'GET',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none'
|
||||
},
|
||||
vars: {
|
||||
req: [
|
||||
{ uid: uuid(), name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_bool', value: 'true', datatype: 'boolean', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_obj', value: '{"scope":"req"}', datatype: 'object', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_str', value: 'plain', enabled: true, local: false }
|
||||
],
|
||||
res: []
|
||||
}
|
||||
};
|
||||
|
||||
const validated = await requestSchema.validate(request);
|
||||
expect(validated.vars.req[0].datatype).toBe('number');
|
||||
expect(validated.vars.req[1].datatype).toBe('boolean');
|
||||
expect(validated.vars.req[2].datatype).toBe('object');
|
||||
expect(validated.vars.req[3].datatype).toBeUndefined();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - custom method', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
|
||||
Reference in New Issue
Block a user