feat: support annotations for secret environment variables in bru and preserve variable value type in yml (#8251)

This commit is contained in:
lohit
2026-06-12 23:18:41 +05:30
committed by GitHub
parent 0d73e38515
commit d8d468f1e0
15 changed files with 682 additions and 26 deletions

View File

@@ -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();
});
});

View File

@@ -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.
*/

View File

@@ -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 || '';
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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' } }
]);
});
});

View File

@@ -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()

View File

@@ -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}
`;

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});
});

View File

@@ -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(

View File

@@ -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',