diff --git a/docs/mcp.md b/docs/mcp.md index bf7b87a..c2ef140 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2029,13 +2029,13 @@ The constraint is also referenced in the [`provar_testcase_generate`](#provar_te Tools that surface Salesforce org metadata to authoring tools without making a live API call. These read from data that has already been written to disk by the Provar IDE — they do **not** trigger metadata downloads themselves and they do **not** require an authenticated session. -> **Distinct from `.provarCaches`:** the runtime cache used by `provar_automation_testrun` lives at `/.provarCaches/` and is regenerated per run. The cache read by `provar_org_describe` lives in the Provar IDE **workspace** (`/.metadata//`) and is updated when a user opens the project and loads a connection in the IDE. +> **Distinct from `.provarCaches`:** the runtime cache used by `provar_automation_testrun` lives at `/.provarCaches/` and is regenerated per run. The cache read by `provar_org_describe` lives in the Provar IDE **workspace** `.metadata` directory and is updated when a user opens the project and loads a connection in the IDE. ### `provar_org_describe` Read cached Salesforce describe data for one connection from the Provar workspace `.metadata` cache. Returns the object list, required-field schema, and a cache age. Use this before calling `provar_testcase_generate` so the generator can produce steps with correctly-typed field values. -**Prerequisite:** the project must have been opened in Provar IDE at least once with the named connection loaded. If the cache is missing, the tool returns a structured response with `details.suggestion` rather than an error. +**Prerequisite:** the project must have been opened in Provar IDE at least once with the named connection loaded, and (for the chosen test environment) the relevant objects expanded so their metadata is written to disk. If the cache is missing, the tool returns a structured response with `details.suggestion` rather than an error. **Workspace discovery heuristic** — the tool walks candidate directories in this order and uses the first one that exists: @@ -2045,12 +2045,20 @@ Read cached Salesforce describe data for one connection from the Provar workspac `` is the project's basename with whitespace collapsed to single dashes and lowercased: `"My Project"` → `"my-project"`. -| Input | Type | Required | Default | Description | -| ----------------- | ----------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `project_path` | string | yes | — | Absolute path to the Provar test project root (the directory containing `.testproject`). Must be within `--allowed-paths`. | -| `connection_name` | string | yes | — | Connection name as defined in `.testproject` (e.g. `"MyOrg"`). Must match the `.metadata` subdirectory exactly. Path separators in this value are rejected (`PATH_TRAVERSAL`). | -| `objects` | string[] | no | all | Filter — only return data for these object API names. When omitted, lists every object cached under the connection directory. | -| `field_filter` | `'required'` \| `'all'` | no | `'required'` | Which fields to return. `'required'` includes only fields with `nillable=false`; `'all'` returns every cached field. | +**Cache layout resolution (within the discovered workspace).** The tool prefers the Provar IDE SfObject cache and falls back to the legacy/native layout: + +1. **Provar IDE SfObject layout (preferred):** `/.metadata/.plugins/com.provar.eclipse.ui///SfObject/.xml` — one XML file per object, written by the IDE when a connection's objects are expanded. `` is the test environment name (e.g. `default`, `UAT`). The tool tries the requested `environment` first; if that environment has no `SfObject` directory, it falls back to any environment that does, preferring `default`. +2. **Legacy / native layout (fallback):** `/.metadata//.{json,xml,object}` — used only when the IDE SfObject layout is absent. + +`cache_age_ms` reflects the `mtime` of whichever cache directory was used (the `SfObject` directory for the IDE layout). + +| Input | Type | Required | Default | Description | +| ----------------- | ----------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `project_path` | string | yes | — | Absolute path to the Provar test project root (the directory containing `.testproject`). Must be within `--allowed-paths`. | +| `connection_name` | string | yes | — | Connection name as defined in `.testproject` (e.g. `"MyOrg"`). Must match the cache subdirectory exactly. String identifier, **not** a file path — path separators or `..` in this value are rejected (`PATH_TRAVERSAL`). | +| `environment` | string | no | `'default'` | Test environment name whose cached metadata to read in the Provar IDE SfObject layout (e.g. `"default"`, `"UAT"`). If the requested environment has no cached metadata the tool falls back to any environment that does, preferring `default`. Ignored for the legacy layout. String identifier, **not** a file path — path separators or `..` are rejected (`PATH_TRAVERSAL`). | +| `objects` | string[] | no | all | Filter — only return data for these object API names. When omitted, lists every object cached under the resolved cache directory. | +| `field_filter` | `'required'` \| `'all'` | no | `'required'` | Which fields to return. `'required'` includes only fields with `nillable=false`; `'all'` returns every cached field. | | Output field | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -2133,7 +2141,27 @@ Read cached Salesforce describe data for one connection from the Provar workspac } ``` -**On-disk cache schema (one file per object).** The tool reads `.json` first, then `.xml`, then `.object` as a fallback: +**On-disk cache schema (one file per object).** + +The **preferred** format is the Provar IDE SfObject XML at `/.metadata/.plugins/com.provar.eclipse.ui///SfObject/.xml`. Fields live under ``: + +```xml + + + + + + + + User + + + +``` + +Field mapping: `name` ← `n` (a field with no `n` is skipped); `type` ← `type` (absent → `"unknown"`; Salesforce booleans appear as `_boolean`); `nillable` ← `NOT(required="true")`; `default_value` is always `null` (this format carries no default). The object display name comes from the `sfObject` `t` attribute, else `n`. A **stub** file (`detailsLoaded="false"` with no ``) is reported as `exists: true, field_count: 0` — not an error. + +The **legacy/native** format (read only when the SfObject layout is absent) lives at `/.metadata//.{json,xml,object}`. JSON is tried first, then `.xml` / `.object` (CustomObject / toolingObjectInfo metadata): ```jsonc // /.metadata//Account.json diff --git a/src/mcp/tools/orgDescribeTools.ts b/src/mcp/tools/orgDescribeTools.ts index 26d5264..1184c87 100644 --- a/src/mcp/tools/orgDescribeTools.ts +++ b/src/mcp/tools/orgDescribeTools.ts @@ -52,12 +52,17 @@ export interface OrgDescribeResult { * On-disk cache schema (one file per object) consumed by this tool. * The cache writer is Provar IDE; this tool is read-only. * - * Layout: /.metadata//.json + * Two layouts are supported, IDE layout first (preferred), then the legacy/native layout: * - * Each JSON file contains: { name: "Account", fields: [ { name, type, defaultValue, nillable }, ... ] } + * 1. Provar IDE SfObject layout (what the IDE actually writes today): + * /.metadata/.plugins/com.provar.eclipse.ui///SfObject/.xml + * One file per object, root element , fields under . + * `` is the test environment name (e.g. "default", "UAT"); defaults to "default". * - * As a fallback, .xml / .object files (CustomObject metadata) are also accepted - * to ease migration from the legacy Provar IDE Eclipse cache layout. + * 2. Legacy / native layout (kept for backward compatibility): + * /.metadata//.{json,xml,object} + * JSON files contain { name, fields: [ { name, type, defaultValue, nillable }, ... ] }; + * .xml / .object files are CustomObject / toolingObjectInfo metadata. */ interface CachedField { name: string; @@ -133,11 +138,17 @@ export function discoverWorkspace(projectPath: string, allowedPaths: string[] = // ── Cache reading ───────────────────────────────────────────────────────────── +// Single parser instance handles BOTH on-disk XML formats: +// - legacy CustomObject / toolingObjectInfo: repeats directly under the root, +// so `fields` must always be coerced to an array. +// - Provar IDE SfObject: a single container holds repeated elements, +// so `sfField` must also be coerced to an array. +// The two element names never collide within one document, so arraying both is safe. const XML_PARSER = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', parseAttributeValue: false, - isArray: (name): boolean => name === 'fields', + isArray: (name): boolean => name === 'fields' || name === 'sfField', }); /** Parse a JSON cache file into the canonical CachedObject shape. */ @@ -147,15 +158,57 @@ function readJsonCacheFile(filePath: string): CachedObject { } /** - * Parse a legacy .object XML file (CustomObject metadata) into the canonical shape. + * Parse the Provar IDE SfObject XML format into the canonical shape. + * + * Layout: ... + * Attribute mapping (fast-xml-parser prefixes attributes with `@_`): + * name ← `@_n` (required; a field with no name is skipped). + * type ← `@_type` (may be absent → 'unknown'; SF booleans appear as '_boolean'). + * nillable ← NOT(`@_required === 'true'`) (required="true" → nillable=false). + * default_value ← null (this format carries no defaultValue attribute). + * The display name is taken from the sfObject `@_t`, else `@_n`, else the file basename. + * Stub files (detailsLoaded="false", no ) yield an empty field list — exists=true, + * field_count=0 — rather than an error. + */ +function readSfObjectXml(sfObject: Record, fallbackName: string): CachedObject { + // `name` is the object API name (@_n, e.g. "provar__Person__c"), mirroring a Salesforce + // describeSObjectResult and the native-JSON cache path. The IDE's @_t is a human display + // label that embeds the key prefix ("Person (a0D)") and must not be used as the API name. + const objectApiName = + (sfObject['@_n'] as string | undefined) ?? (sfObject['@_t'] as string | undefined) ?? fallbackName; + + // is arrayed by XML_PARSER; the (single) container holds the children. + const fieldsContainers = sfObject['fields']; + if (!Array.isArray(fieldsContainers) || fieldsContainers.length === 0) { + return { name: objectApiName, fields: [] }; + } + const container = fieldsContainers[0] as Record; + const sfFieldsRaw = container['sfField']; + if (!Array.isArray(sfFieldsRaw)) return { name: objectApiName, fields: [] }; + + const fields: CachedField[] = []; + for (const f of sfFieldsRaw as Array>) { + const name = f['@_n'] as string | undefined; + if (!name) continue; // skip nameless container artefacts + // required="true" → nillable=false; absent → nillable=true (optional). + const isRequired = f['@_required'] === 'true' || f['@_required'] === true; + fields.push({ + name, + type: (f['@_type'] as string | undefined) ?? 'unknown', + defaultValue: null, + nillable: !isRequired, + }); + } + return { name: objectApiName, fields }; +} + +/** + * Parse a legacy CustomObject / toolingObjectInfo .xml/.object file into the canonical shape. * Only extracts the bare minimum the tool needs: field name, type, nillable. */ -function readXmlCacheFile(filePath: string): CachedObject { - const raw = fs.readFileSync(filePath, 'utf-8'); - const parsed = XML_PARSER.parse(raw) as Record; - const root = (parsed['CustomObject'] ?? parsed['toolingObjectInfo'] ?? {}) as Record; +function readCustomObjectXml(root: Record, fallbackName: string): CachedObject { const fieldsRaw = root['fields']; - if (!Array.isArray(fieldsRaw)) return { name: path.basename(filePath, path.extname(filePath)), fields: [] }; + if (!Array.isArray(fieldsRaw)) return { name: fallbackName, fields: [] }; const fields: CachedField[] = []; for (const f of fieldsRaw as Array>) { @@ -175,7 +228,26 @@ function readXmlCacheFile(filePath: string): CachedObject { nillable: !isRequired, }); } - return { name: path.basename(filePath, path.extname(filePath)), fields }; + return { name: fallbackName, fields }; +} + +/** + * Parse an XML cache file into the canonical CachedObject shape. Detects the format from + * the root element: (Provar IDE layout) vs / + * (legacy/native layout). + */ +function readXmlCacheFile(filePath: string): CachedObject { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = XML_PARSER.parse(raw) as Record; + const fallbackName = path.basename(filePath, path.extname(filePath)); + + const sfObject = parsed['sfObject']; + if (sfObject && typeof sfObject === 'object') { + return readSfObjectXml(sfObject as Record, fallbackName); + } + + const root = (parsed['CustomObject'] ?? parsed['toolingObjectInfo'] ?? {}) as Record; + return readCustomObjectXml(root, fallbackName); } /** Look up the cache file for one object, trying .json then .xml. */ @@ -275,45 +347,129 @@ function cacheMissSuggestion(connectionName: string): string { interface DescribeArgs { project_path: string; connection_name: string; + environment?: string; objects?: string[]; field_filter?: 'required' | 'all'; } +/** Eclipse plugin id under which the Provar IDE writes its SfObject metadata cache. */ +const ECLIPSE_UI_PLUGIN = 'com.provar.eclipse.ui'; +const DEFAULT_ENVIRONMENT = 'default'; + +/** True when dir exists and is a directory (any error → false). */ +function isExistingDir(dir: string): boolean { + try { + return fs.existsSync(dir) && fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} + /** - * Resolve & policy-check the workspace + connection directory. - * Returns the connection directory if it exists and is allowed, otherwise null. + * Reject path-shaped connection / environment names outright. A real connection or + * environment name is an identifier (e.g. "MyOrg", "UAT"); any separator or traversal + * segment is almost certainly a misuse or injection attempt. + */ +function assertIdentifier(value: string, fieldName: string): void { + const hasSeparator = value.includes('/') || value.includes('\\'); + const hasTraversal = value === '..' || value.split(/[/\\]+/).includes('..'); + if (hasSeparator || hasTraversal) { + throw new PathPolicyError( + 'PATH_TRAVERSAL', + `Invalid ${fieldName} (must not contain path separators or directory-traversal segments ('..')): ${value}` + ); + } +} + +/** + * Resolve the Provar IDE SfObject cache directory for a connection + environment, if present. + * + * Layout: /.metadata/.plugins/com.provar.eclipse.ui///SfObject + * Resolution: try the requested first; if its SfObject dir is missing, scan the + * connection dir for ANY /SfObject that exists (preferring `default`). + * + * Path policy is enforced on the eclipse.ui dir, the connection dir, and every candidate + * SfObject dir before any further filesystem call against it. Returns null when no usable + * SfObject directory exists under policy. + */ +function resolveSfObjectDir( + resolvedWorkspace: string, + connectionName: string, + environment: string, + allowedPaths: string[] +): string | null { + const pluginDir = path.resolve(resolvedWorkspace, '.metadata', '.plugins', ECLIPSE_UI_PLUGIN); + assertPathAllowed(pluginDir, allowedPaths); + + const connectionDir = path.resolve(pluginDir, connectionName); + assertPathAllowed(connectionDir, allowedPaths); + if (!isExistingDir(connectionDir)) return null; + + // Build ordered env candidates: requested env first, then `default`, then any others. + const requested = path.resolve(connectionDir, environment, 'SfObject'); + assertPathAllowed(requested, allowedPaths); + if (isExistingDir(requested)) return requested; + + let envDirs: string[]; + try { + envDirs = fs.readdirSync(connectionDir); + } catch { + return null; + } + // Prefer `default`, then alphabetical for determinism. + const ordered = [...envDirs].sort((a, b) => { + if (a === DEFAULT_ENVIRONMENT) return -1; + if (b === DEFAULT_ENVIRONMENT) return 1; + return a.localeCompare(b); + }); + for (const env of ordered) { + if (env === environment) continue; // already tried + const candidate = path.resolve(connectionDir, env, 'SfObject'); + try { + assertPathAllowed(candidate, allowedPaths); + } catch { + continue; // outside policy — skip silently + } + if (isExistingDir(candidate)) return candidate; + } + return null; +} + +/** + * Resolve & policy-check the workspace + cache directory. + * + * Prefers the Provar IDE SfObject layout + * (`/.metadata/.plugins/com.provar.eclipse.ui///SfObject`), + * falling back to the legacy/native layout (`/.metadata/`). + * Returns the cache directory if one exists and is allowed, otherwise null. */ function resolveConnectionDir( workspacePath: string | null, connectionName: string, + environment: string, allowedPaths: string[] ): { connectionDir: string | null; resolvedWorkspace: string | null } { if (!workspacePath) return { connectionDir: null, resolvedWorkspace: null }; - // Reject path-shaped connection names outright. A real connection name from a - // .testproject is an identifier (e.g. "MyOrg"); any separator or traversal - // segment is almost certainly a misuse or injection attempt. - const hasSeparator = connectionName.includes('/') || connectionName.includes('\\'); - const hasTraversal = connectionName === '..' || connectionName.split(/[/\\]+/).includes('..'); - if (hasSeparator || hasTraversal) { - throw new PathPolicyError( - 'PATH_TRAVERSAL', - `Invalid connection_name (must not contain path separators or directory-traversal segments ('..')): ${connectionName}` - ); - } + assertIdentifier(connectionName, 'connection_name'); + assertIdentifier(environment, 'environment'); // Path policy: workspace MUST be inside allowed paths before any fs call against it. const resolvedWorkspace = path.resolve(workspacePath); assertPathAllowed(resolvedWorkspace, allowedPaths); - const connectionDir = path.resolve(resolvedWorkspace, '.metadata', connectionName); - // Belt-and-braces check after composition. - assertPathAllowed(connectionDir, allowedPaths); + // Preferred: Provar IDE SfObject layout. + const sfObjectDir = resolveSfObjectDir(resolvedWorkspace, connectionName, environment, allowedPaths); + if (sfObjectDir) return { connectionDir: sfObjectDir, resolvedWorkspace }; - if (!fs.existsSync(connectionDir) || !fs.statSync(connectionDir).isDirectory()) { + // Fallback: legacy/native /.metadata/. + const legacyDir = path.resolve(resolvedWorkspace, '.metadata', connectionName); + // Belt-and-braces check after composition. + assertPathAllowed(legacyDir, allowedPaths); + if (!isExistingDir(legacyDir)) { return { connectionDir: null, resolvedWorkspace }; } - return { connectionDir, resolvedWorkspace }; + return { connectionDir: legacyDir, resolvedWorkspace }; } function buildCacheMissResponse( @@ -364,9 +520,13 @@ export function registerOrgDescribe(server: McpServer, config: ServerConfig): vo [ 'Read cached Salesforce describe data for one connection from the Provar workspace .metadata cache.', 'Prerequisite: the project must have been opened in Provar IDE at least once with the named connection loaded', - '— this tool is read-only and does NOT trigger a metadata download.', + '(and, for the named test environment, the relevant objects expanded) — this tool is read-only and does NOT trigger a metadata download.', 'Workspace discovery tries in order: /workspace-, ', '/Provar_Workspaces/workspace-, then ~/Provar/workspace-.', + 'Within the workspace it prefers the Provar IDE SfObject cache at', + '.metadata/.plugins/com.provar.eclipse.ui///SfObject/.xml', + '(environment defaults to "default"; if the requested environment is absent it falls back to any cached environment),', + 'and falls back to the legacy .metadata//.{json,xml,object} layout.', 'Returns an empty result with details.suggestion when the cache is missing.', 'Distinct from the runtime .provarCaches cache used by test execution.', ].join(' '), @@ -385,10 +545,20 @@ export function registerOrgDescribe(server: McpServer, config: ServerConfig): vo .string() .describe( desc( - 'Connection name as defined in the .testproject file (e.g. "MyOrg"). The .metadata cache subdirectory must match this exactly.', + 'Connection name as defined in the .testproject file (e.g. "MyOrg"). The cache subdirectory must match this exactly. String identifier, NOT a file path — path separators or ".." are rejected (PATH_TRAVERSAL).', 'string, connection name as defined in .testproject' ) ), + environment: z + .string() + .optional() + .default('default') + .describe( + desc( + 'Test environment name whose cached metadata to read in the Provar IDE SfObject layout (e.g. "default", "UAT"). Defaults to "default". If the requested environment has no cached metadata, the tool falls back to any environment that does (preferring "default"). Ignored for the legacy .metadata/ layout. String identifier, NOT a file path — path separators or ".." are rejected (PATH_TRAVERSAL).', + "string, test environment name; default 'default'" + ) + ), objects: z .array(z.string()) .optional() @@ -424,6 +594,7 @@ export function registerOrgDescribe(server: McpServer, config: ServerConfig): vo const { connectionDir, resolvedWorkspace } = resolveConnectionDir( workspacePath, args.connection_name, + args.environment ?? DEFAULT_ENVIRONMENT, config.allowedPaths ); diff --git a/test/unit/mcp/orgDescribeTools.test.ts b/test/unit/mcp/orgDescribeTools.test.ts index e0b851d..2ac588f 100644 --- a/test/unit/mcp/orgDescribeTools.test.ts +++ b/test/unit/mcp/orgDescribeTools.test.ts @@ -90,6 +90,68 @@ function writeXmlCache( fs.writeFileSync(path.join(connectionDir, `${objectName}${ext}`), xml, 'utf-8'); } +/** + * Resolve the Provar IDE SfObject cache directory for a connection + environment: + * /.metadata/.plugins/com.provar.eclipse.ui///SfObject + */ +function sfObjectDir(workspace: string, connection: string, env: string): string { + return path.join(workspace, '.metadata', '.plugins', 'com.provar.eclipse.ui', connection, env, 'SfObject'); +} + +/** A single spec for the IDE SfObject fixture format. */ +interface SfFieldSpec { + name: string; + type?: string; // omit → no `type` attribute (reader should default to 'unknown') + required?: boolean; // required="true" → nillable=false + /** Emit a child so the field is a container — children must NOT be counted. */ + referenceTo?: string; +} + +/** + * Write a Provar IDE SfObject XML file (sanitized shape based on the real IDE output) into + * /.metadata/.plugins/com.provar.eclipse.ui///SfObject/.xml. + * When `detailsLoaded` is false the file is a self-closing stub with no element. + */ +function writeSfObjectCache( + workspace: string, + connection: string, + env: string, + objectName: string, + displayName: string, + fields: SfFieldSpec[], + detailsLoaded = true +): void { + const dir = sfObjectDir(workspace, connection, env); + fs.mkdirSync(dir, { recursive: true }); + + let xml: string; + if (!detailsLoaded) { + xml = + '\n' + + `\n`; + } else { + const sfFieldsXml = fields + .map((f) => { + const attrs = [`n="${f.name}"`]; + if (f.type !== undefined) attrs.push(`type="${f.type}"`); + if (f.required) attrs.push('required="true"'); + if (f.referenceTo) attrs.push('relationshipName="Owner"'); + const open = ` \n \n ${f.referenceTo}\n \n `; + } + return `${open}/>`; + }) + .join('\n'); + xml = + '\n' + + `\n` + + ` \n${sfFieldsXml}\n \n` + + '\n'; + } + fs.writeFileSync(path.join(dir, `${objectName}.xml`), xml, 'utf-8'); +} + // ── Test setup ───────────────────────────────────────────────────────────────── let tmpRoot: string; @@ -592,3 +654,228 @@ describe('provar_org_describe — connection_name validation', () => { ); }); }); + +// ── (k) Provar IDE SfObject layout ──────────────────────────────────────────── + +describe('provar_org_describe — Provar IDE SfObject layout', () => { + it('(k.1) parses // and reports field_count + required_fields', () => { + // Fixture modelled on the real provar__Person__c.xml: a mix of typed fields, a + // required field, a type-less field, and a container field with . + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'UAT', 'provar__Person__c', 'Person', [ + { name: 'Id', type: 'id' }, + { name: 'OwnerId', type: 'reference', referenceTo: 'User' }, // container: child must not count + { name: 'Name' }, // no type → 'unknown' + { name: 'provar__Email__c', type: 'email', required: true }, // the only required field + { name: 'provar__Start_Date__c', type: 'date' }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + environment: 'UAT', + objects: ['provar__Person__c'], + field_filter: 'all', + }); + + assert.equal(isError(result), false); + const body = parseText(result); + const objects = body['objects'] as Array<{ + name: string; + exists: boolean | null; + required_fields: Array<{ name: string; type: string; nillable: boolean; default_value: string | null }>; + field_count: number; + }>; + assert.equal(objects.length, 1); + assert.equal(objects[0].exists, true); + // 5 sfFields; the children of must NOT be counted. + assert.equal(objects[0].field_count, 5, 'container children must not be double-counted'); + assert.equal( + objects[0].name, + 'provar__Person__c', + 'object name is the API name from sfObject @n, not the @t label' + ); + + const byName = new Map(objects[0].required_fields.map((f) => [f.name, f])); + assert.equal(byName.get('Name')?.type, 'unknown', 'field with no type attribute → "unknown"'); + assert.equal(byName.get('provar__Email__c')?.nillable, false, 'required="true" → nillable=false'); + assert.equal(byName.get('Id')?.nillable, true, 'absent required → nillable=true'); + assert.equal(byName.get('provar__Email__c')?.default_value, null, 'SfObject format has no defaultValue'); + }); + + it('(k.2) field_filter=required returns only the required field', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'UAT', 'provar__Person__c', 'Person', [ + { name: 'Id', type: 'id' }, + { name: 'provar__Email__c', type: 'email', required: true }, + { name: 'Name' }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + environment: 'UAT', + objects: ['provar__Person__c'], + field_filter: 'required', + }); + + const body = parseText(result); + const objects = body['objects'] as Array<{ required_fields: Array<{ name: string }>; field_count: number }>; + assert.equal(objects[0].field_count, 3, 'field_count reports all fields, not the filtered subset'); + const names = objects[0].required_fields.map((f) => f.name); + assert.deepEqual(names, ['provar__Email__c'], 'only the required field passes field_filter=required'); + }); + + it('(k.3) falls back to an existing env (default) when requested env missing', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + // Only `default` has cached metadata; the request asks for `UAT`. + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [ + { name: 'Name', type: 'string', required: true }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + environment: 'UAT', + objects: ['Account'], + field_filter: 'all', + }); + + assert.equal(isError(result), false); + const body = parseText(result); + const objects = body['objects'] as Array<{ name: string; exists: boolean | null; field_count: number }>; + assert.equal(objects[0].exists, true, 'should fall back to the default environment cache'); + assert.equal(objects[0].field_count, 1); + }); + + it('(k.4) defaults environment to "default" when omitted', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [ + { name: 'Name', type: 'string', required: true }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + objects: ['Account'], + }); + + assert.equal(isError(result), false); + const body = parseText(result); + const objects = body['objects'] as Array<{ name: string; exists: boolean | null }>; + assert.equal(objects[0].exists, true); + }); + + it('(k.5) treats a stub (detailsLoaded=false, no ) as exists=true, field_count=0', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [], false); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + objects: ['Account'], + field_filter: 'all', + }); + + assert.equal(isError(result), false); + const body = parseText(result); + const objects = body['objects'] as Array<{ + name: string; + exists: boolean | null; + field_count: number; + error_message?: string; + }>; + assert.equal(objects[0].exists, true, 'stub file exists'); + assert.equal(objects[0].field_count, 0, 'stub has no loaded fields'); + assert.equal(objects[0].error_message, undefined, 'a stub is not a parse error'); + }); + + it('(k.6) lists all cached objects in the SfObject dir when objects omitted', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [{ name: 'Name', type: 'string' }]); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Contact', 'Contact', [ + { name: 'LastName', type: 'string' }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + }); + + const body = parseText(result); + assert.ok(typeof body['cache_age_ms'] === 'number', 'cache_age_ms reflects the SfObject dir mtime'); + const objects = body['objects'] as Array<{ name: string }>; + // Display names come from sfObject @t; both objects use their own name here. + const names = objects.map((o) => o.name).sort(); + assert.deepEqual(names, ['Account', 'Contact']); + }); + + it('(k.7) prefers the IDE SfObject layout over the legacy .metadata/ layout', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + // Legacy layout has 1 field; IDE layout has 2. IDE must win. + writeJsonCache(path.join(siblingWorkspace, '.metadata', 'Admin'), 'Account', [ + { name: 'LegacyOnly', type: 'string', defaultValue: null, nillable: false }, + ]); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [ + { name: 'Name', type: 'string', required: true }, + { name: 'Phone', type: 'phone' }, + ]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + objects: ['Account'], + field_filter: 'all', + }); + + const body = parseText(result); + const objects = body['objects'] as Array<{ field_count: number; required_fields: Array<{ name: string }> }>; + assert.equal(objects[0].field_count, 2, 'IDE SfObject layout should be preferred over legacy'); + const names = objects[0].required_fields.map((f) => f.name).sort(); + assert.deepEqual(names, ['Name', 'Phone']); + }); + + it('(k.8) rejects an environment containing a path separator with PATH_TRAVERSAL', () => { + const siblingWorkspace = path.join(tmpRoot, 'workspace-MyProject'); + writeSfObjectCache(siblingWorkspace, 'Admin', 'default', 'Account', 'Account', [{ name: 'Name', type: 'string' }]); + + const result = server.call('provar_org_describe', { + project_path: projectPath, + connection_name: 'Admin', + environment: '../../escape', + objects: ['Account'], + }); + + assert.equal(isError(result), true); + const code = parseText(result)['error_code'] as string; + assert.ok(code === 'PATH_TRAVERSAL' || code === 'PATH_NOT_ALLOWED', `Unexpected error_code: ${code}`); + }); + + it('(k.9) rejects a workspace SfObject path outside allowedPaths', () => { + // Build a workspace OUTSIDE the allowed root and point project discovery at it via a + // sibling whose parent is outside tmpRoot. The IDE-layout SfObject dir then sits + // outside allowedPaths and must be rejected by the path policy. + const outsideRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'org-describe-out-'))); + try { + const outsideProject = path.join(outsideRoot, 'OutProject'); + fs.mkdirSync(outsideProject, { recursive: true }); + const outsideWorkspace = path.join(outsideRoot, 'workspace-OutProject'); + writeSfObjectCache(outsideWorkspace, 'Admin', 'default', 'Account', 'Account', [ + { name: 'Name', type: 'string' }, + ]); + + // Server allows ONLY tmpRoot, but project_path/workspace live under outsideRoot. + const result = server.call('provar_org_describe', { + project_path: outsideProject, + connection_name: 'Admin', + objects: ['Account'], + }); + + assert.equal(isError(result), true, 'a workspace outside allowedPaths must be rejected'); + const code = parseText(result)['error_code'] as string; + assert.ok(code === 'PATH_NOT_ALLOWED' || code === 'PATH_TRAVERSAL', `Unexpected error_code: ${code}`); + } finally { + fs.rmSync(outsideRoot, { recursive: true, force: true }); + } + }); +});