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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/DeployManual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
# Auto-extract from git log since the previous tag
if [ "${{ github.event_name }}" = "release" ]; then
# Release event: HEAD is the new tag — find the nearest ancestor tag before it
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git tag --sort=-version:refname | tail -1)
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git tag --sort=-version:refname | head -1)
else
# Manual dispatch: find the nearest ancestor tag from HEAD
# (git describe respects branch ancestry; avoids pulling in commits from sibling branches)
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/security/pathPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export function assertPathAllowed(filePath: string, allowedPaths: string[]): voi
resolvedAllowed.length > 0 &&
!resolvedAllowed.some((base) => {
const baseKey = normalizeForCompare(base);
return resolvedKey === baseKey || resolvedKey.startsWith(baseKey + path.sep);
// Strip a trailing separator so roots like '/' or 'C:\' don't produce '//' or 'C:\\'
const baseKeyNorm = baseKey.endsWith(path.sep) ? baseKey.slice(0, -1) : baseKey;
return resolvedKey === baseKey || resolvedKey.startsWith(baseKeyNorm + path.sep);
})
) {
throw new PathPolicyError(
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/tools/testPlanTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,15 @@ export function registerTestPlanAddInstance(server: McpServer, config: ServerCon
};
}

// Resolve testcase absolute path — normalize backslashes so Windows-style paths work on macOS/Linux
// Resolve testcase absolute path — normalize backslashes so Windows-style paths work on macOS/Linux.
// Check for '..' before path.join() normalizes them away; otherwise traversal goes undetected
// when allowedPaths is empty (unrestricted mode). Then enforce containment on the resolved path.
const normalizedTestCasePath = toForwardSlashes(test_case_path);
if (normalizedTestCasePath.split('/').some((seg) => seg === '..')) {
throw new PathPolicyError('PATH_TRAVERSAL', `Path traversal detected in test_case_path: ${test_case_path}`);
}
const absoluteTestCasePath = path.join(projectRoot, normalizedTestCasePath);
assertPathAllowed(absoluteTestCasePath, config.allowedPaths);
if (!fs.existsSync(absoluteTestCasePath)) {
return {
isError: true,
Expand Down
117 changes: 117 additions & 0 deletions test/unit/mcp/testCaseGenerate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,4 +834,121 @@ describe('provar_testcase_generate', () => {
assert.ok(!('validation' in body), 'validation field should be absent when validate_after_edit=false');
});
});

describe('F1/F3 — compound value emission for embedded {VarName} tokens', () => {
it('emits class="compound" with <parts> when a SOQL query embeds a variable (F1)', () => {
const result = server.call('provar_testcase_generate', {
test_case_name: 'SOQL Compound Test',
steps: [
{
api_id: 'ApexSoqlQuery',
name: 'Query account',
attributes: { soqlQuery: "SELECT Id, Name FROM Account WHERE Id = '{AccountId}'" },
},
],
dry_run: true,
overwrite: false,
validate_after_edit: false,
});

assert.equal(isError(result), false);
const xml = parseText(result)['xml_content'] as string;
assert.ok(xml.includes('class="compound"'), 'Expected class="compound" for embedded variable in SOQL');
assert.ok(xml.includes('<parts>'), 'Expected <parts> element inside compound value');
assert.ok(xml.includes('<variable>'), 'Expected <variable> element for the AccountId reference');
assert.ok(xml.includes('<path element="AccountId"/>'), 'Expected <path element="AccountId"/>');
assert.ok(
!xml.includes('valueClass="string">{AccountId}'),
'Must NOT emit {AccountId} as a plain string literal'
);
});

it('emits class="compound" for Provar system variables embedded in a string (F3: {NOW})', () => {
const result = server.call('provar_testcase_generate', {
test_case_name: 'NOW Compound Test',
steps: [
{
api_id: 'SetValues',
name: 'Set account name',
attributes: { AccountName: 'Acme Corp CRUD Test {NOW}' },
},
],
dry_run: true,
overwrite: false,
validate_after_edit: false,
});

assert.equal(isError(result), false);
const xml = parseText(result)['xml_content'] as string;
assert.ok(xml.includes('class="compound"'), 'Expected class="compound" inside namedValues');
assert.ok(xml.includes('<path element="NOW"/>'), 'Expected <path element="NOW"/> for system variable');
assert.ok(
!xml.includes('valueClass="string">Acme Corp CRUD Test {NOW}'),
'Must NOT emit {NOW} as a literal string'
);
});

it('emits <parts> with correct literal fragments around the variable', () => {
const result = server.call('provar_testcase_generate', {
test_case_name: 'Fragment Test',
steps: [
{
api_id: 'ApexSoqlQuery',
name: 'Query with prefix and suffix',
attributes: { soqlQuery: "SELECT Id FROM Contact WHERE Email = '{Email}' LIMIT 1" },
},
],
dry_run: true,
overwrite: false,
validate_after_edit: false,
});

const xml = parseText(result)['xml_content'] as string;
assert.ok(xml.includes("SELECT Id FROM Contact WHERE Email = '"), 'Expected literal prefix fragment');
assert.ok(xml.includes("' LIMIT 1"), 'Expected literal suffix fragment');
assert.ok(xml.includes('<path element="Email"/>'), 'Expected variable path element');
});

it('handles multiple embedded variables in one string', () => {
const result = server.call('provar_testcase_generate', {
test_case_name: 'Multi Var Test',
steps: [
{
api_id: 'ApexSoqlQuery',
name: 'Query by two fields',
attributes: { soqlQuery: "SELECT Id FROM Case WHERE AccountId='{AccId}' AND OwnerId='{OwnerId}'" },
},
],
dry_run: true,
overwrite: false,
validate_after_edit: false,
});

const xml = parseText(result)['xml_content'] as string;
assert.ok(xml.includes('<path element="AccId"/>'), 'Expected first variable path');
assert.ok(xml.includes('<path element="OwnerId"/>'), 'Expected second variable path');
const compoundCount = (xml.match(/class="compound"/g) ?? []).length;
assert.equal(compoundCount, 1, 'Should be exactly one compound element for the soqlQuery argument');
});

it('pure {VarName} value (entire argument) still uses class="variable", not compound', () => {
const result = server.call('provar_testcase_generate', {
test_case_name: 'Pure Var Test',
steps: [
{
api_id: 'ApexDeleteObject',
name: 'Delete account',
attributes: { recordId: '{AccountId}' },
},
],
dry_run: true,
overwrite: false,
validate_after_edit: false,
});

const xml = parseText(result)['xml_content'] as string;
assert.ok(xml.includes('class="variable"'), 'Pure {VarName} should use class="variable"');
assert.ok(!xml.includes('class="compound"'), 'Pure {VarName} must NOT use class="compound"');
});
});
});
22 changes: 22 additions & 0 deletions test/unit/mcp/testPlanTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,28 @@ describe('provar_testplan_add-instance', () => {
});
});

describe('path policy on test_case_path', () => {
it('returns PATH_TRAVERSAL when test_case_path contains .. (rejected before path.join normalizes it)', () => {
makeProject(projectDir);
makePlan(projectDir, 'P');

// Use unrestricted server (empty allowedPaths) to confirm '..' is caught even without containment check
const unrestrictedServer = new MockMcpServer();
registerAllTestPlanTools(unrestrictedServer as never, { allowedPaths: [] });

const result = unrestrictedServer.call('provar_testplan_add-instance', {
project_path: projectDir,
test_case_path: '../outside.testcase',
plan_name: 'P',
overwrite: false,
dry_run: false,
});

assert.equal(isError(result), true);
assert.equal(errorCode(result), 'PATH_TRAVERSAL');
});
});

describe('testCasePath forward-slash normalization', () => {
it('normalizes backslashes to forward slashes in written XML', () => {
makeProject(projectDir);
Expand Down
Loading