diff --git a/.github/workflows/deploy-ci-backend.yml b/.github/workflows/deploy-ci-backend.yml index 19aa914..a9ea8c7 100644 --- a/.github/workflows/deploy-ci-backend.yml +++ b/.github/workflows/deploy-ci-backend.yml @@ -145,12 +145,16 @@ jobs: echo "🔐 Populating CI Key Vault secrets..." KEY_VAULT_NAME="docgen-ci-kv" + SECRET_DIR="${RUNNER_TEMP}/docgen-ci-secrets" + mkdir -p "${SECRET_DIR}" # Set SF private key (for Node -> Salesforce JWT Bearer flow) - echo "${{ secrets.CI_SF_PRIVATE_KEY }}" | az keyvault secret set \ + printf '%s' "${{ secrets.CI_SF_PRIVATE_KEY }}" > "${SECRET_DIR}/sf-private-key.pem" + az keyvault secret set \ --vault-name ${KEY_VAULT_NAME} \ --name SF-PRIVATE-KEY \ - --value @/dev/stdin \ + --file "${SECRET_DIR}/sf-private-key.pem" \ + --encoding utf-8 \ --output none # Set SF client ID (Connected App) @@ -177,10 +181,19 @@ jobs: # Set SFDX Auth URL if provided (for scratch org authentication) if [ -n "${{ inputs.sfdx_auth_url }}" ]; then echo "Setting SFDX-AUTH-URL from workflow input..." - echo "${{ inputs.sfdx_auth_url }}" | az keyvault secret set \ + SFDX_AUTH_URL="${{ inputs.sfdx_auth_url }}" + + if [[ "$SFDX_AUTH_URL" != force://* ]]; then + echo "❌ Provided SFDX Auth URL does not use the expected force:// scheme" + exit 1 + fi + + printf '%s' "$SFDX_AUTH_URL" > "${SECRET_DIR}/sfdx-auth-url.txt" + az keyvault secret set \ --vault-name ${KEY_VAULT_NAME} \ --name SFDX-AUTH-URL \ - --value @/dev/stdin \ + --file "${SECRET_DIR}/sfdx-auth-url.txt" \ + --encoding utf-8 \ --output none echo "✅ SFDX-AUTH-URL secret set" else @@ -199,6 +212,7 @@ jobs: --output none echo "✅ CI Key Vault secrets populated" + rm -f "${SECRET_DIR}/sf-private-key.pem" "${SECRET_DIR}/sfdx-auth-url.txt" - name: Restart Container App (if SFDX Auth URL provided) if: inputs.sfdx_auth_url != '' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f001644..6c92c4f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -192,12 +192,16 @@ jobs: echo "🔐 Populating CI Key Vault secrets..." KEY_VAULT_NAME="docgen-ci-kv" + SECRET_DIR="${RUNNER_TEMP}/docgen-ci-secrets" + mkdir -p "${SECRET_DIR}" # Set SF private key (for Node -> Salesforce JWT Bearer flow) - echo "${{ secrets.CI_SF_PRIVATE_KEY }}" | az keyvault secret set \ + printf '%s' "${{ secrets.CI_SF_PRIVATE_KEY }}" > "${SECRET_DIR}/sf-private-key.pem" + az keyvault secret set \ --vault-name ${KEY_VAULT_NAME} \ --name SF-PRIVATE-KEY \ - --value @/dev/stdin \ + --file "${SECRET_DIR}/sf-private-key.pem" \ + --encoding utf-8 \ --output none # Set SF client ID (Connected App) @@ -221,18 +225,34 @@ jobs: --value "test.salesforce.com" \ --output none - # Set SFDX Auth URL (from earlier scratch org creation) - SFDX_AUTH_URL=$(sf org display --verbose --json | jq -r '.result.sfdxAuthUrl') + # Set direct scratch org access token auth for the Node backend. + sf org display \ + --verbose \ + --json \ + --target-org "${{ steps.create-org.outputs.org_alias }}" \ + > "${SECRET_DIR}/scratch-org-display.json" + + SF_ACCESS_TOKEN=$(jq -r '.result.accessToken // empty' "${SECRET_DIR}/scratch-org-display.json") + SF_INSTANCE_URL=$(jq -r '.result.instanceUrl // empty' "${SECRET_DIR}/scratch-org-display.json") - if [ -z "$SFDX_AUTH_URL" ] || [ "$SFDX_AUTH_URL" = "null" ]; then - echo "❌ Failed to extract SFDX Auth URL from org" + if [ -z "$SF_ACCESS_TOKEN" ] || [ -z "$SF_INSTANCE_URL" ]; then + echo "❌ Failed to extract scratch org access token or instance URL" + jq '{status, resultKeys: (.result | keys)}' "${SECRET_DIR}/scratch-org-display.json" exit 1 fi - echo "$SFDX_AUTH_URL" | az keyvault secret set \ + printf '%s' "$SF_ACCESS_TOKEN" > "${SECRET_DIR}/sf-access-token.txt" + az keyvault secret set \ + --vault-name ${KEY_VAULT_NAME} \ + --name SF-ACCESS-TOKEN \ + --file "${SECRET_DIR}/sf-access-token.txt" \ + --encoding utf-8 \ + --output none + + az keyvault secret set \ --vault-name ${KEY_VAULT_NAME} \ - --name SFDX-AUTH-URL \ - --value @/dev/stdin \ + --name SF-INSTANCE-URL \ + --value "$SF_INSTANCE_URL" \ --output none # Set Azure Monitor connection string @@ -246,7 +266,8 @@ jobs: --value "${APP_INSIGHTS_CONN}" \ --output none - echo "✅ CI Key Vault secrets populated (including scratch org SFDX Auth URL)" + echo "✅ CI Key Vault secrets populated (including scratch org access token auth)" + rm -f "${SECRET_DIR}/sf-private-key.pem" "${SECRET_DIR}/sf-access-token.txt" "${SECRET_DIR}/scratch-org-display.json" - name: Restart Container App to pick up all secrets run: | @@ -271,16 +292,40 @@ jobs: id: org-info run: | # Get org info (org is already authenticated and set as default) - sf org display --json > org-info.json + sf org display \ + --verbose \ + --json \ + --target-org "${{ steps.create-org.outputs.org_alias }}" \ + > org-info.json # Extract credentials and set as environment variables INSTANCE_URL=$(jq -r '.result.instanceUrl' org-info.json) ACCESS_TOKEN=$(jq -r '.result.accessToken' org-info.json) USERNAME=$(jq -r '.result.username' org-info.json) + ORG_ID=$(jq -r '.result.id // empty' org-info.json) + + if [ -z "$INSTANCE_URL" ] || [ "$INSTANCE_URL" = "null" ] || [ "$INSTANCE_URL" = "undefined" ]; then + echo "❌ Failed to extract scratch org instance URL" + jq '{status, resultKeys: (.result | keys)}' org-info.json + exit 1 + fi + + if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ] || [ "$ACCESS_TOKEN" = "undefined" ]; then + echo "❌ Failed to extract scratch org access token" + jq '{status, resultKeys: (.result | keys)}' org-info.json + exit 1 + fi + + if [ -z "$USERNAME" ] || [ "$USERNAME" = "null" ] || [ "$USERNAME" = "undefined" ]; then + echo "❌ Failed to extract scratch org username" + jq '{status, resultKeys: (.result | keys)}' org-info.json + exit 1 + fi echo "SF_INSTANCE_URL=$INSTANCE_URL" >> $GITHUB_ENV echo "SF_ACCESS_TOKEN=$ACCESS_TOKEN" >> $GITHUB_ENV echo "SF_USERNAME=$USERNAME" >> $GITHUB_ENV + echo "SF_ORG_ID=$ORG_ID" >> $GITHUB_ENV echo "Instance URL: $INSTANCE_URL" echo "Username: $USERNAME" diff --git a/e2e/utils/scratch-org.ts b/e2e/utils/scratch-org.ts index 2c564f5..3b309ce 100644 --- a/e2e/utils/scratch-org.ts +++ b/e2e/utils/scratch-org.ts @@ -1,4 +1,4 @@ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); @@ -10,18 +10,92 @@ export interface ScratchOrgInfo { orgId: string; } +function isMissingOrgValue(value: unknown): boolean { + if (typeof value !== 'string') { + return true; + } + const normalized = value.trim().toLowerCase(); + return normalized === '' || normalized === 'undefined' || normalized === 'null'; +} + +function shellArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function getScratchOrgInfoFromEnv(): ScratchOrgInfo | null { + const instanceUrl = process.env.SF_INSTANCE_URL; + const accessToken = process.env.SF_ACCESS_TOKEN; + const username = process.env.SF_USERNAME; + + if ( + isMissingOrgValue(instanceUrl) || + isMissingOrgValue(accessToken) || + isMissingOrgValue(username) + ) { + return null; + } + + return { + instanceUrl: instanceUrl!.replace(/\/$/, ''), + accessToken: accessToken!, + username: username!, + orgId: process.env.SF_ORG_ID || '', + }; +} + +interface RestCreateResponse { + success?: boolean; + id?: string; + errors?: unknown[]; +} + +function execFileCommand( + command: string, + args: string[], + options: Parameters[2] +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, options, (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + return; + } + + resolve({ + stdout: stdout.toString(), + stderr: stderr.toString(), + }); + }); + }); +} + +function getSfCliEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.SF_ACCESS_TOKEN; + delete env.SF_INSTANCE_URL; + delete env.SF_ORG_ID; + env.SF_FORMAT_JSON = 'true'; + env.SF_DISABLE_COLORS = 'true'; + return env; +} + /** * Get information about the current default scratch org * Uses `sf org display --json` to fetch credentials */ export async function getScratchOrgInfo(): Promise { try { + const envOrgInfo = getScratchOrgInfoFromEnv(); + if (envOrgInfo) { + return envOrgInfo; + } + // Get org info from SFDX CLI // Use SF_FORMAT_JSON=true to ensure clean JSON output without colors // In CI, use SF_USERNAME env var if available to explicitly target the org const targetOrg = process.env.SF_USERNAME; - const targetOrgFlag = targetOrg ? ` --target-org ${targetOrg}` : ''; - const { stdout, stderr } = await execAsync(`sf org display${targetOrgFlag} --json`, { + const targetOrgFlag = targetOrg ? ` --target-org ${shellArg(targetOrg)}` : ''; + const { stdout, stderr } = await execAsync(`sf org display${targetOrgFlag} --verbose --json`, { env: { ...process.env, SF_FORMAT_JSON: 'true', SF_DISABLE_COLORS: 'true' } }); @@ -41,17 +115,21 @@ export async function getScratchOrgInfo(): Promise { const result = orgData.result; // Check if we have the required fields - if (!result.instanceUrl || !result.accessToken || !result.username) { + if ( + isMissingOrgValue(result.instanceUrl) || + isMissingOrgValue(result.accessToken) || + isMissingOrgValue(result.username) + ) { throw new Error( - 'Missing required org information. Make sure a scratch org is set as default.' + `Missing required org information. Result keys: ${Object.keys(result).join(', ')}` ); } return { - instanceUrl: result.instanceUrl, + instanceUrl: result.instanceUrl.replace(/\/$/, ''), accessToken: result.accessToken, username: result.username, - orgId: result.id, + orgId: result.id || '', }; } catch (error) { if (error instanceof Error) { @@ -122,44 +200,60 @@ async function createRecordViaRestAPI( objectType: string, fields: Record ): Promise { - const orgInfo = await getScratchOrgInfo(); - - // Write JSON to temp file + // Write JSON to temp file. Use the Salesforce CLI REST command so auth stays + // inside the CLI org connection instead of manually passing a bearer token. const fs = require('fs'); - const tempFile = `/tmp/sf-record-rest-${Date.now()}.json`; + const tempFile = `/tmp/sf-record-rest-${Date.now()}-${Math.random().toString(36).substring(7)}.json`; fs.writeFileSync(tempFile, JSON.stringify(fields)); try { - const url = `${orgInfo.instanceUrl}/services/data/v65.0/sobjects/${objectType}`; - - const command = `curl -X POST "${url}" \\ - -H "Authorization: Bearer ${orgInfo.accessToken}" \\ - -H "Content-Type: application/json" \\ - -d @${tempFile}`; + const targetOrg = process.env.SF_USERNAME; + const args = [ + 'api', + 'request', + 'rest', + `/services/data/v65.0/sobjects/${objectType}`, + '--body', + `@${tempFile}`, + '--method', + 'POST', + ]; + + if (targetOrg) { + args.push('--target-org', targetOrg); + } - const { stdout } = await execAsync(command, { - maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large responses + const { stdout, stderr } = await execFileCommand('sf', args, { + env: getSfCliEnv(), + maxBuffer: 10 * 1024 * 1024, }); - const result = JSON.parse(stdout); - - // Clean up temp file - if (fs.existsSync(tempFile)) { - fs.unlinkSync(tempFile); + if (stderr) { + console.error('SF CLI stderr (REST):', stderr); } + const cleanStdout = stdout.replace(/\x1B\[[0-9;]*[mGKHF]/g, '').trim(); + const result = JSON.parse(cleanStdout) as RestCreateResponse; + if (!result.success || !result.id) { throw new Error(`REST API creation failed: ${JSON.stringify(result)}`); } return result.id; } catch (error) { - // Clean up temp file on error - const fs = require('fs'); + const stdout = error instanceof Error ? (error as any).stdout || '' : ''; + const stderr = error instanceof Error ? (error as any).stderr || '' : ''; + const stderrInfo = stderr ? `\nStderr: ${stderr}` : ''; + const stdoutInfo = stdout ? `\nStdout: ${stdout}` : ''; + + if (error instanceof Error) { + throw new Error(`REST API creation failed: ${error.message}${stdoutInfo}${stderrInfo}`); + } + throw error; + } finally { if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } - throw error; } } diff --git a/force-app/main/default/classes/DocgenController.cls b/force-app/main/default/classes/DocgenController.cls index c75d4e4..2d2e10e 100644 --- a/force-app/main/default/classes/DocgenController.cls +++ b/force-app/main/default/classes/DocgenController.cls @@ -551,14 +551,19 @@ public with sharing class DocgenController { Map body = (Map) JSON.deserializeUntyped(response.getBody()); String contentVersionId = (String) body.get('contentVersionId'); String responseCorrelationId = (String) body.get('correlationId'); + String docxContentVersionId = getOptionalString(body, 'docxContentVersionId'); // Update record to SUCCEEDED // Note: CorrelationId__c was already set during insert and should not be modified - update new Generated_Document__c( + Generated_Document__c updateDoc = new Generated_Document__c( Id = doc.Id, Status__c = 'SUCCEEDED', OutputFileId__c = contentVersionId ); + if (String.isNotBlank(docxContentVersionId)) { + updateDoc.MergedDocxFileId__c = docxContentVersionId; + } + update updateDoc; // Build and return download URL return buildDownloadUrl(contentVersionId); @@ -615,13 +620,18 @@ public with sharing class DocgenController { } String contentVersionId = (String) contentVersionIdObj; + String docxContentVersionId = getOptionalString(body, 'docxContentVersionId'); // Update record to SUCCEEDED - update new Generated_Document__c( + Generated_Document__c updateDoc = new Generated_Document__c( Id = doc.Id, Status__c = 'SUCCEEDED', OutputFileId__c = contentVersionId ); + if (String.isNotBlank(docxContentVersionId)) { + updateDoc.MergedDocxFileId__c = docxContentVersionId; + } + update updateDoc; // Return success result return successResult( @@ -649,6 +659,14 @@ public with sharing class DocgenController { } } + private static String getOptionalString(Map body, String key) { + if (body == null || !body.containsKey(key)) { + return null; + } + Object value = body.get(key); + return value instanceof String ? (String) value : null; + } + /** * Parse rich error response from backend * Extracts all error details including code, message, stack, timestamp, and context diff --git a/force-app/main/default/classes/DocgenControllerTest.cls b/force-app/main/default/classes/DocgenControllerTest.cls index f1a09a2..c97a3dd 100644 --- a/force-app/main/default/classes/DocgenControllerTest.cls +++ b/force-app/main/default/classes/DocgenControllerTest.cls @@ -38,6 +38,28 @@ private class DocgenControllerTest { } } + /** + * Mock HTTP callout for successful PDF generation with stored merged DOCX + */ + private class MockDocgenSuccessWithMergedDocxCallout implements HttpCalloutMock { + public HTTPResponse respond(HTTPRequest req) { + HTTPResponse res = new HTTPResponse(); + res.setStatusCode(200); + res.setStatus('OK'); + res.setHeader('Content-Type', 'application/json'); + + Map responseBody = new Map{ + 'downloadUrl' => 'https://test.my.salesforce.com/sfc/servlet.shepherd/version/download/068XXXXXXXXXXXXXXX', + 'contentVersionId' => '068XXXXXXXXXXXXXXX', + 'docxContentVersionId' => '068ZZZZZZZZZZZZZZZ', + 'correlationId' => 'test-correlation-id-with-docx' + }; + res.setBody(JSON.serialize(responseBody)); + + return res; + } + } + /** * Mock HTTP callout for server error (500) */ @@ -193,6 +215,37 @@ private class DocgenControllerTest { Assert.isNotNull(doc.RequestJSON__c, 'RequestJSON should be stored'); } + /** + * Test: Successful PDF generation stores merged DOCX file ID when backend returns it + */ + @isTest + static void testGenerateSuccessStoresMergedDocxFileId() { + // Given + Docgen_Template__c template = [SELECT Id, StoreMergedDocx__c FROM Docgen_Template__c LIMIT 1]; + template.StoreMergedDocx__c = true; + update template; + Account acc = [SELECT Id FROM Account LIMIT 1]; + + Test.setMock(HttpCalloutMock.class, new MockDocgenSuccessWithMergedDocxCallout()); + + // When + Test.startTest(); + DocgenController.GenerateResult result = DocgenController.generate(template.Id, acc.Id, 'PDF'); + Test.stopTest(); + + // Then + Assert.isTrue(result.success, 'Result should indicate success'); + Generated_Document__c doc = [ + SELECT Status__c, OutputFileId__c, MergedDocxFileId__c + FROM Generated_Document__c + WHERE Account__c = :acc.Id + LIMIT 1 + ]; + Assert.areEqual('SUCCEEDED', doc.Status__c, 'Status should be SUCCEEDED'); + Assert.areEqual('068XXXXXXXXXXXXXXX', doc.OutputFileId__c, 'Primary output file should be stored'); + Assert.areEqual('068ZZZZZZZZZZZZZZZ', doc.MergedDocxFileId__c, 'Merged DOCX file should be stored'); + } + /** * Test 2: Server error handling (500 response) * Given: Valid request but server fails diff --git a/force-app/main/default/classes/DocgenTestDataFactory.cls b/force-app/main/default/classes/DocgenTestDataFactory.cls index 030bee3..5489888 100644 --- a/force-app/main/default/classes/DocgenTestDataFactory.cls +++ b/force-app/main/default/classes/DocgenTestDataFactory.cls @@ -10,6 +10,8 @@ */ @IsTest public class DocgenTestDataFactory { + private static String defaultLeadStatus; + /** * Container for test scenario data @@ -126,6 +128,7 @@ public class DocgenTestDataFactory { BillingCity = 'San Francisco', AnnualRevenue = 1000000 ); + applyAccountCountry(acc); insertWithDuplicateBypass(acc); return acc; } @@ -138,11 +141,13 @@ public class DocgenTestDataFactory { public static List createAccounts(Integer count) { List accounts = new List(); for (Integer i = 1; i <= count; i++) { - accounts.add(new Account( + Account acc = new Account( Name = 'Test Account ' + i, BillingCity = 'San Francisco', AnnualRevenue = 100000 * i - )); + ); + applyAccountCountry(acc); + accounts.add(acc); } insertWithDuplicateBypass(accounts); return accounts; @@ -227,8 +232,9 @@ public class DocgenTestDataFactory { LastName = lastName, Company = company, Email = firstName.toLowerCase() + '.' + lastName.toLowerCase() + '@example.com', - Status = 'Open - Not Contacted' + Status = getDefaultLeadStatus() ); + applyLeadCountry(ld); insertWithDuplicateBypass(ld); return ld; } @@ -241,13 +247,15 @@ public class DocgenTestDataFactory { public static List createLeads(Integer count) { List leads = new List(); for (Integer i = 1; i <= count; i++) { - leads.add(new Lead( + Lead ld = new Lead( FirstName = 'Test', LastName = 'Lead ' + i, Company = 'Test Company ' + i, Email = 'test.lead' + i + '@example.com', - Status = 'Open - Not Contacted' - )); + Status = getDefaultLeadStatus() + ); + applyLeadCountry(ld); + leads.add(ld); } insertWithDuplicateBypass(leads); return leads; @@ -604,11 +612,13 @@ public class DocgenTestDataFactory { String suffix = index > 0 ? ' ' + index : ''; if (objectApiName == 'Account') { - return new Account( + Account acc = new Account( Name = 'Test Account' + suffix, BillingCity = 'San Francisco', AnnualRevenue = 1000000 ); + applyAccountCountry(acc); + return acc; } else if (objectApiName == 'Contact') { return new Contact( FirstName = 'Test', @@ -616,16 +626,19 @@ public class DocgenTestDataFactory { Email = 'test' + index + '@example.com' ); } else if (objectApiName == 'Lead') { - return new Lead( + Lead ld = new Lead( FirstName = 'Test', LastName = 'Lead' + suffix, Company = 'Test Company' + suffix, Email = 'testlead' + index + '@example.com', - Status = 'Open - Not Contacted' + Status = getDefaultLeadStatus() ); + applyLeadCountry(ld); + return ld; } else if (objectApiName == 'Opportunity') { // Need an Account for Opportunity (bypass duplicate rules) Account acc = new Account(Name = 'Test Account for Opp' + suffix); + applyAccountCountry(acc); insertWithDuplicateBypass(acc); return new Opportunity( @@ -638,6 +651,7 @@ public class DocgenTestDataFactory { } else if (objectApiName == 'Case') { // Optionally link to Account (bypass duplicate rules) Account acc = new Account(Name = 'Test Account for Case' + suffix); + applyAccountCountry(acc); insertWithDuplicateBypass(acc); return new Case( @@ -859,6 +873,47 @@ public class DocgenTestDataFactory { // DUPLICATE RULE BYPASS HELPER // ========================================================================= + private static void applyAccountCountry(Account acc) { + if (acc == null) { + return; + } + + Map accountFields = Account.SObjectType.getDescribe().fields.getMap(); + if (accountFields.containsKey('BillingCountryCode')) { + acc.put('BillingCountryCode', 'JP'); + } else { + acc.BillingCountry = 'Japan'; + } + } + + private static void applyLeadCountry(Lead ld) { + if (ld == null) { + return; + } + + Map leadFields = Lead.SObjectType.getDescribe().fields.getMap(); + if (leadFields.containsKey('CountryCode')) { + ld.put('CountryCode', 'JP'); + } else { + ld.Country = 'Japan'; + } + } + + private static String getDefaultLeadStatus() { + if (defaultLeadStatus != null) { + return defaultLeadStatus; + } + + List statuses = [ + SELECT MasterLabel + FROM LeadStatus + WHERE IsDefault = true + LIMIT 1 + ]; + defaultLeadStatus = statuses.isEmpty() ? 'Open - Not Contacted' : statuses[0].MasterLabel; + return defaultLeadStatus; + } + /** * Insert a record while bypassing duplicate rules. * Use this in test methods to avoid duplicate detection blocking test data creation. diff --git a/openapi.yaml b/openapi.yaml index e5c6d83..d29df0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -240,6 +240,7 @@ paths: example: downloadUrl: 'https://example.my.salesforce.com/sfc/servlet.shepherd/version/download/068xx000000abcdXXX' contentVersionId: '068xx000000abcdXXX' + docxContentVersionId: '068xx000000docxYYY' correlationId: '12345678-1234-4567-89ab-123456789012' '202': description: Request accepted for processing (async/batch mode) @@ -664,6 +665,11 @@ components: description: | Salesforce ContentVersionId (18 chars) of the uploaded file. Can be used for subsequent operations. example: '068xx000000abcdXXX' + docxContentVersionId: + type: string + description: | + Optional Salesforce ContentVersionId of the stored merged DOCX file when `storeMergedDocx` is true. + example: '068xx000000docxYYY' correlationId: type: string format: uuid diff --git a/scripts/provision-ci-backend.sh b/scripts/provision-ci-backend.sh index ead2718..c764b27 100755 --- a/scripts/provision-ci-backend.sh +++ b/scripts/provision-ci-backend.sh @@ -180,10 +180,11 @@ populate_secrets() { log_info "Populating CI Key Vault secrets..." # Set SF private key - cat "$CI_SF_PRIVATE_KEY_PATH" | az keyvault secret set \ + az keyvault secret set \ --vault-name "$KEY_VAULT_NAME" \ --name SF-PRIVATE-KEY \ - --value @/dev/stdin \ + --file "$CI_SF_PRIVATE_KEY_PATH" \ + --encoding utf-8 \ --output none # Set SF client ID diff --git a/sfdx-project.json b/sfdx-project.json index d4c9efc..ee380c0 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "ver 0.3.4", - "versionNumber": "0.3.4.NEXT", + "versionName": "ver 0.3.5", + "versionNumber": "0.3.5.NEXT", "path": "force-app/main", "default": true, "package": "docgen", @@ -27,6 +27,7 @@ "docgen@0.3.1-1": "04tWS000001jy7XYAQ", "docgen@0.3.2-1": "04tWS000002TpVpYAK", "docgen@0.3.3-1": "04tWS000002UXlNYAW", - "docgen@0.3.4-1": "04tWS000002UYmHYAW" + "docgen@0.3.4-1": "04tWS000002UYmHYAW", + "docgen@0.3.5-1": "04tWS000002ZSczYAG" } -} +} \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts index e0d9a28..32615a8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -60,6 +60,9 @@ export async function loadConfig(): Promise { sfPrivateKey: loadPrivateKey(), // Salesforce SFDX Auth URL (alternative to JWT Bearer) sfdxAuthUrl: process.env.SFDX_AUTH_URL, + // Salesforce access token (short-lived CI/scratch org auth) + sfAccessToken: process.env.SF_ACCESS_TOKEN, + sfInstanceUrl: process.env.SF_INSTANCE_URL, // LibreOffice conversion settings (T-11) conversionTimeout: parseInt( process.env.CONVERSION_TIMEOUT || '60000', @@ -108,6 +111,12 @@ export async function loadConfig(): Promise { if (kvSecrets.sfdxAuthUrl) { config.sfdxAuthUrl = kvSecrets.sfdxAuthUrl; } + if (kvSecrets.sfAccessToken) { + config.sfAccessToken = kvSecrets.sfAccessToken; + } + if (kvSecrets.sfInstanceUrl) { + config.sfInstanceUrl = kvSecrets.sfInstanceUrl; + } } return config; @@ -117,7 +126,7 @@ export async function loadConfig(): Promise { * Validate required configuration for production * * Note: Salesforce auth validation is handled in SalesforceAuth class. - * Either JWT Bearer config or SFDX Auth URL is required, but not enforced here. + * JWT Bearer config, SFDX Auth URL, or direct access token config is required. */ export function validateConfig(config: AppConfig): void { if (config.nodeEnv === 'production') { @@ -140,10 +149,11 @@ export function validateConfig(config: AppConfig): void { // Validate that at least one Salesforce auth method is configured const hasJwtConfig = !!(config.sfDomain && config.sfUsername && config.sfClientId && config.sfPrivateKey); const hasSfdxConfig = !!config.sfdxAuthUrl; + const hasAccessTokenConfig = !!(config.sfAccessToken && config.sfInstanceUrl); - if (!hasJwtConfig && !hasSfdxConfig) { + if (!hasJwtConfig && !hasSfdxConfig && !hasAccessTokenConfig) { throw new Error( - 'Production requires Salesforce authentication: either JWT Bearer config (SF_DOMAIN, SF_USERNAME, SF_CLIENT_ID, SF_PRIVATE_KEY) or SFDX_AUTH_URL' + 'Production requires Salesforce authentication: JWT Bearer config (SF_DOMAIN, SF_USERNAME, SF_CLIENT_ID, SF_PRIVATE_KEY), SFDX_AUTH_URL, or SF_ACCESS_TOKEN with SF_INSTANCE_URL' ); } } diff --git a/src/config/secrets.ts b/src/config/secrets.ts index 8d7ce4b..af8082d 100644 --- a/src/config/secrets.ts +++ b/src/config/secrets.ts @@ -13,6 +13,8 @@ export interface KeyVaultSecrets { sfUsername?: string; sfDomain?: string; sfdxAuthUrl?: string; + sfAccessToken?: string; + sfInstanceUrl?: string; azureMonitorConnectionString?: string; } @@ -25,6 +27,8 @@ const SECRET_NAMES = { SF_USERNAME: 'SF-USERNAME', SF_DOMAIN: 'SF-DOMAIN', SFDX_AUTH_URL: 'SFDX-AUTH-URL', + SF_ACCESS_TOKEN: 'SF-ACCESS-TOKEN', + SF_INSTANCE_URL: 'SF-INSTANCE-URL', AZURE_MONITOR_CONNECTION_STRING: 'AZURE-MONITOR-CONNECTION-STRING', } as const; @@ -111,6 +115,12 @@ export async function loadSecretsFromKeyVault(keyVaultUri: string): Promise { type: 'string', description: 'The uploaded file ContentVersion ID', }, + docxContentVersionId: { + type: 'string', + description: 'Secondary merged DOCX ContentVersion ID when storeMergedDocx is true', + }, correlationId: { type: 'string', description: 'Correlation ID for tracking', diff --git a/src/server.ts b/src/server.ts index 6f557cb..ee24b93 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,12 +25,15 @@ export async function build(): Promise { initializeAppInsights(); // Initialize Salesforce authentication if configured - // Supports both JWT Bearer Flow and SFDX Auth URL + // Supports direct access token, JWT Bearer Flow, and SFDX Auth URL + const hasAccessTokenConfig = !!(config.sfAccessToken && config.sfInstanceUrl); const hasJwtConfig = !!(config.sfDomain && config.sfUsername && config.sfClientId && config.sfPrivateKey); const hasSfdxConfig = !!config.sfdxAuthUrl; - if (hasJwtConfig || hasSfdxConfig) { + if (hasAccessTokenConfig || hasJwtConfig || hasSfdxConfig) { createSalesforceAuth({ + sfAccessToken: config.sfAccessToken, + sfInstanceUrl: config.sfInstanceUrl, sfDomain: config.sfDomain, sfUsername: config.sfUsername, sfClientId: config.sfClientId, diff --git a/src/sf/auth.ts b/src/sf/auth.ts index d4aa76c..97c4fbe 100644 --- a/src/sf/auth.ts +++ b/src/sf/auth.ts @@ -15,6 +15,9 @@ function isAxiosError(error: unknown): error is { response?: { status: number; d } export interface SalesforceAuthConfig { + // Direct access token (short-lived CI/scratch orgs) + sfAccessToken?: string; + sfInstanceUrl?: string; // JWT Bearer Flow fields (production/Connected App) sfDomain?: string; sfUsername?: string; @@ -39,10 +42,11 @@ interface ParsedSfdxAuthUrl { * Salesforce Authentication * * Supports two authentication methods: - * 1. JWT Bearer Flow (production/Connected App) - Server-to-server auth with private key - * 2. SFDX Auth URL (development/scratch orgs) - Refresh token flow from sf CLI + * 1. Direct access token (short-lived CI/scratch orgs) + * 2. JWT Bearer Flow (production/Connected App) - Server-to-server auth with private key + * 3. SFDX Auth URL (development/scratch orgs) - Refresh token flow from sf CLI * - * SFDX Auth URL takes precedence if both are configured. + * Direct access token takes precedence, followed by SFDX Auth URL and then JWT. * Caches tokens with TTL and 60-second expiry buffer. * * References: @@ -64,10 +68,12 @@ export class SalesforceAuth { * Validate required configuration * * Requires either: + * - Direct token: sfAccessToken, sfInstanceUrl * - JWT Bearer: sfDomain, sfUsername, sfClientId, sfPrivateKey * - SFDX Auth URL: sfdxAuthUrl */ private validateConfig(config: SalesforceAuthConfig): void { + const hasAccessTokenConfig = !!(config.sfAccessToken && config.sfInstanceUrl); const hasJwtConfig = !!( config.sfDomain && config.sfUsername && @@ -76,15 +82,21 @@ export class SalesforceAuth { ); const hasSfdxConfig = !!config.sfdxAuthUrl; - if (!hasJwtConfig && !hasSfdxConfig) { + if (!hasAccessTokenConfig && !hasJwtConfig && !hasSfdxConfig) { throw new Error( 'Salesforce authentication requires either:\n' + - ' 1. JWT Bearer Flow: SF_DOMAIN, SF_USERNAME, SF_CLIENT_ID, SF_PRIVATE_KEY\n' + - ' 2. SFDX Auth URL: SFDX_AUTH_URL\n' + + ' 1. Direct Access Token: SF_ACCESS_TOKEN, SF_INSTANCE_URL\n' + + ' 2. JWT Bearer Flow: SF_DOMAIN, SF_USERNAME, SF_CLIENT_ID, SF_PRIVATE_KEY\n' + + ' 3. SFDX Auth URL: SFDX_AUTH_URL\n' + 'Get SFDX Auth URL via: sf org display --verbose --json | jq -r \'.result.sfdxAuthUrl\'' ); } + if (hasAccessTokenConfig) { + logger.info('Using direct Salesforce access token authentication'); + return; + } + if (hasJwtConfig && hasSfdxConfig) { logger.warn( 'Both JWT Bearer and SFDX Auth URL configured. SFDX Auth URL takes precedence.' @@ -116,15 +128,36 @@ export class SalesforceAuth { * Example: force://PlatformCLI::!refreshToken123@test.salesforce.com */ private parseSfdxAuthUrl(authUrl: string): ParsedSfdxAuthUrl { - const match = authUrl.match(/^force:\/\/([^:]+):([^:]*):([^@]+)@(.+)$/); + if (!authUrl.startsWith('force://')) { + throw new Error( + 'Invalid format. Expected: force://::@' + ); + } + + const authParts = authUrl.slice('force://'.length); + const instanceSeparatorIndex = authParts.lastIndexOf('@'); + if (instanceSeparatorIndex < 0) { + throw new Error( + 'Invalid format. Expected: force://::@' + ); + } + + const credentials = authParts.slice(0, instanceSeparatorIndex); + const instanceUrl = authParts.slice(instanceSeparatorIndex + 1); + const firstColonIndex = credentials.indexOf(':'); + const secondColonIndex = firstColonIndex >= 0 + ? credentials.indexOf(':', firstColonIndex + 1) + : -1; - if (!match) { + if (firstColonIndex <= 0 || secondColonIndex < 0) { throw new Error( 'Invalid format. Expected: force://::@' ); } - const [, clientId, clientSecret, refreshToken, instanceUrl] = match; + const clientId = credentials.slice(0, firstColonIndex); + const clientSecret = credentials.slice(firstColonIndex + 1, secondColonIndex); + const refreshToken = credentials.slice(secondColonIndex + 1); if (!clientId || !refreshToken || !instanceUrl) { throw new Error('Missing required components in SFDX Auth URL'); @@ -134,7 +167,7 @@ export class SalesforceAuth { clientId, clientSecret: clientSecret || undefined, refreshToken, - instanceUrl: instanceUrl.replace(/\/$/, ''), // Remove trailing slash + instanceUrl: instanceUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''), // Normalize protocol/trailing slash }; } @@ -205,6 +238,11 @@ export class SalesforceAuth { return `https://${this.parsedSfdxAuth.instanceUrl}`; } + // If using direct access token auth, return its instance URL + if (this.config.sfInstanceUrl) { + return this.config.sfInstanceUrl.replace(/\/$/, ''); + } + // Fallback to configured domain (for JWT Bearer Flow) if (this.config.sfDomain) { return `https://${this.config.sfDomain}`; @@ -217,10 +255,16 @@ export class SalesforceAuth { * Fetch new access token from Salesforce * * Routes to appropriate authentication method: + * - Direct access token if configured * - SFDX Auth URL (refresh token flow) if configured * - JWT Bearer Flow otherwise */ private async fetchAccessToken(): Promise { + // Prefer direct access token when configured + if (this.config.sfAccessToken && this.config.sfInstanceUrl) { + return this.useConfiguredAccessToken(); + } + // Prefer SFDX Auth URL if configured if (this.config.sfdxAuthUrl && this.parsedSfdxAuth) { return this.fetchAccessTokenFromRefreshToken(); @@ -230,6 +274,25 @@ export class SalesforceAuth { return this.fetchAccessTokenViaJwt(); } + /** + * Use a pre-issued Salesforce access token. + * + * This is intended for short-lived CI scratch orgs where the Salesforce CLI + * already exposes a valid access token for the org under test. + */ + private async useConfiguredAccessToken(): Promise { + const accessToken = this.config.sfAccessToken!; + const instanceUrl = this.config.sfInstanceUrl!.replace(/\/$/, ''); + + this.cachedToken = { + accessToken, + expiresAt: Date.now() + 3600 * 1000, + instanceUrl, + }; + + return accessToken; + } + /** * Fetch access token using SFDX Auth URL (refresh token flow) */ diff --git a/src/types.ts b/src/types.ts index 474864f..e77107f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,9 @@ export interface AppConfig { sfPrivateKey?: string; // Salesforce SFDX Auth URL (alternative to JWT Bearer) sfdxAuthUrl?: string; + // Salesforce access token (short-lived CI/scratch org auth) + sfAccessToken?: string; + sfInstanceUrl?: string; // LibreOffice conversion settings (T-11) conversionTimeout: number; conversionWorkdir: string; @@ -164,6 +167,7 @@ export interface DocgenRequest { export interface DocgenResponse { downloadUrl: string; contentVersionId: string; + docxContentVersionId?: string; correlationId: string; } diff --git a/src/worker/poller.ts b/src/worker/poller.ts index c1ac40a..1edeaf0 100644 --- a/src/worker/poller.ts +++ b/src/worker/poller.ts @@ -283,7 +283,7 @@ export class PollerService { async fetchQueuedDocuments(): Promise { try { const sfAuth = getSalesforceAuth(); - if (!sfAuth || !getConfig().sfDomain) { + if (!sfAuth) { throw new MissingConfigurationError('Salesforce authentication'); } const sfApi = new SalesforceApi(sfAuth, sfAuth.getInstanceUrl()); @@ -322,7 +322,7 @@ export class PollerService { async lockDocument(documentId: string): Promise { try { const sfAuth = getSalesforceAuth(); - if (!sfAuth || !getConfig().sfDomain) { + if (!sfAuth) { throw new MissingConfigurationError('Salesforce authentication'); } const sfApi = new SalesforceApi(sfAuth, sfAuth.getInstanceUrl()); @@ -359,7 +359,7 @@ export class PollerService { try { // Initialize Salesforce API and template service const sfAuth = getSalesforceAuth(); - if (!sfAuth || !getConfig().sfDomain) { + if (!sfAuth) { throw new MissingConfigurationError('Salesforce authentication', { correlationId: doc.CorrelationId__c }); } const sfApi = new SalesforceApi(sfAuth, sfAuth.getInstanceUrl()); @@ -606,7 +606,7 @@ export class PollerService { ): Promise { try { const sfAuth = getSalesforceAuth(); - if (!sfAuth || !getConfig().sfDomain) { + if (!sfAuth) { throw new MissingConfigurationError('Salesforce authentication'); } const sfApi = new SalesforceApi(sfAuth, sfAuth.getInstanceUrl()); @@ -641,7 +641,7 @@ export class PollerService { try { const sfAuth = getSalesforceAuth(); - if (!sfAuth || !getConfig().sfDomain) { + if (!sfAuth) { throw new MissingConfigurationError('Salesforce authentication'); } const sfApi = new SalesforceApi(sfAuth, sfAuth.getInstanceUrl()); diff --git a/test/auth.test.ts b/test/auth.test.ts index 564b446..9bfa73b 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -10,6 +10,31 @@ import { getMockJWKS, } from './helpers/jwt-helper'; import { build } from '../src/server'; +import { resetSalesforceAuth } from '../src/sf/auth'; + +const SALESFORCE_ENV_KEYS = [ + 'SF_DOMAIN', + 'SF_USERNAME', + 'SF_CLIENT_ID', + 'SF_PRIVATE_KEY', + 'SF_PRIVATE_KEY_PATH', + 'SFDX_AUTH_URL', +] as const; + +function clearSalesforceAuthEnv(): void { + resetSalesforceAuth(); + for (const key of SALESFORCE_ENV_KEYS) { + delete process.env[key]; + } +} + +beforeEach(() => { + clearSalesforceAuthEnv(); +}); + +afterEach(() => { + resetSalesforceAuth(); +}); describe('Azure AD JWT Authentication', () => { let app: FastifyInstance; @@ -46,7 +71,9 @@ describe('Azure AD JWT Authentication', () => { }); afterEach(async () => { - await app.close(); + if (app) { + await app.close(); + } nock.cleanAll(); }); @@ -453,7 +480,9 @@ describe('Auth Integration with Health Endpoints', () => { }); afterEach(async () => { - await app.close(); + if (app) { + await app.close(); + } nock.cleanAll(); }); @@ -493,4 +522,4 @@ describe('Auth Integration with Health Endpoints', () => { expect(body.checks).toHaveProperty('jwks'); } }); -}); \ No newline at end of file +}); diff --git a/test/config.secrets.test.ts b/test/config.secrets.test.ts index ad9f3b1..c907cd5 100644 --- a/test/config.secrets.test.ts +++ b/test/config.secrets.test.ts @@ -38,7 +38,7 @@ describe('Key Vault Secrets Loader', () => { }); describe('loadSecretsFromKeyVault', () => { - it('should successfully load all 5 secrets from Key Vault', async () => { + it('should successfully load configured secrets from Key Vault', async () => { // Mock secret responses mockSecretClient.getSecret .mockResolvedValueOnce({ @@ -66,6 +66,16 @@ describe('Key Vault Secrets Loader', () => { name: 'SFDX-AUTH-URL', properties: {}, } as any) + .mockResolvedValueOnce({ + value: 'scratch-access-token', + name: 'SF-ACCESS-TOKEN', + properties: {}, + } as any) + .mockResolvedValueOnce({ + value: 'https://scratch.example.my.salesforce.com', + name: 'SF-INSTANCE-URL', + properties: {}, + } as any) .mockResolvedValueOnce({ value: 'InstrumentationKey=test-key', name: 'AZURE-MONITOR-CONNECTION-STRING', @@ -79,15 +89,19 @@ describe('Key Vault Secrets Loader', () => { sfClientId: 'test-client-id', sfUsername: 'test@example.com', sfDomain: 'test.salesforce.com', + sfAccessToken: 'scratch-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com', azureMonitorConnectionString: 'InstrumentationKey=test-key', }); - expect(mockSecretClient.getSecret).toHaveBeenCalledTimes(6); + expect(mockSecretClient.getSecret).toHaveBeenCalledTimes(8); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-PRIVATE-KEY'); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-CLIENT-ID'); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-USERNAME'); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-DOMAIN'); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SFDX-AUTH-URL'); + expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-ACCESS-TOKEN'); + expect(mockSecretClient.getSecret).toHaveBeenCalledWith('SF-INSTANCE-URL'); expect(mockSecretClient.getSecret).toHaveBeenCalledWith('AZURE-MONITOR-CONNECTION-STRING'); }); @@ -119,6 +133,16 @@ describe('Key Vault Secrets Loader', () => { name: 'SFDX-AUTH-URL', properties: {}, } as any) + .mockResolvedValueOnce({ + value: undefined, + name: 'SF-ACCESS-TOKEN', + properties: {}, + } as any) + .mockResolvedValueOnce({ + value: undefined, + name: 'SF-INSTANCE-URL', + properties: {}, + } as any) .mockResolvedValueOnce({ value: 'InstrumentationKey=test-key', name: 'AZURE-MONITOR-CONNECTION-STRING', @@ -187,6 +211,16 @@ describe('Key Vault Secrets Loader', () => { name: 'SFDX-AUTH-URL', properties: {}, } as any) + .mockResolvedValueOnce({ + value: undefined, + name: 'SF-ACCESS-TOKEN', + properties: {}, + } as any) + .mockResolvedValueOnce({ + value: undefined, + name: 'SF-INSTANCE-URL', + properties: {}, + } as any) .mockResolvedValueOnce({ value: 'InstrumentationKey=test-key', name: 'AZURE-MONITOR-CONNECTION-STRING', @@ -227,6 +261,21 @@ describe('Key Vault Secrets Loader', () => { name: 'SF-DOMAIN', properties: {}, } as any) + .mockResolvedValueOnce({ + value: '', + name: 'SFDX-AUTH-URL', + properties: {}, + } as any) + .mockResolvedValueOnce({ + value: '', + name: 'SF-ACCESS-TOKEN', + properties: {}, + } as any) + .mockResolvedValueOnce({ + value: '', + name: 'SF-INSTANCE-URL', + properties: {}, + } as any) .mockResolvedValueOnce({ value: '', name: 'AZURE-MONITOR-CONNECTION-STRING', diff --git a/test/config.test.ts b/test/config.test.ts index bbd1b56..ae8735e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -526,6 +526,82 @@ describe('Config', () => { }); }); + describe('Salesforce Access Token Configuration', () => { + it('should load direct Salesforce access token config from environment variables', async () => { + process.env.SF_ACCESS_TOKEN = 'env-access-token'; + process.env.SF_INSTANCE_URL = 'https://scratch.example.my.salesforce.com'; + + const config = await loadConfig(); + + expect(config.sfAccessToken).toBe('env-access-token'); + expect(config.sfInstanceUrl).toBe('https://scratch.example.my.salesforce.com'); + }); + + it('should load direct Salesforce access token config from Key Vault in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.KEY_VAULT_URI = 'https://test-kv.vault.azure.net/'; + + mockLoadSecretsFromKeyVault.mockResolvedValue({ + sfAccessToken: 'kv-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com', + }); + + const config = await loadConfig(); + + expect(config.sfAccessToken).toBe('kv-access-token'); + expect(config.sfInstanceUrl).toBe('https://scratch.example.my.salesforce.com'); + }); + + it('should accept production config with direct access token only', () => { + const config: AppConfig = { + port: 8080, + nodeEnv: 'production', + logLevel: 'info', + azureTenantId: 'tenant-id', + clientId: 'client-id', + keyVaultUri: 'https://vault.azure.net/', + issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', + audience: 'api://client-id', + jwksUri: 'https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys', + sfAccessToken: 'scratch-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com', + conversionTimeout: 60000, + conversionWorkdir: '/tmp', + conversionMaxConcurrent: 8, + poller: { + intervalMs: 15000, + idleIntervalMs: 60000, + batchSize: 20, + lockTtlMs: 120000, + maxAttempts: 3, + }, + enableTelemetry: true, + }; + + expect(() => validateConfig(config)).not.toThrow(); + }); + + it('should load and validate production config with direct access token auth from env', async () => { + process.env.NODE_ENV = 'production'; + process.env.SF_ACCESS_TOKEN = 'scratch-access-token'; + process.env.SF_INSTANCE_URL = 'https://scratch.example.my.salesforce.com'; + process.env.AZURE_TENANT_ID = 'tenant-id'; + process.env.CLIENT_ID = 'client-id'; + process.env.KEY_VAULT_URI = 'https://vault.azure.net/'; + process.env.ISSUER = 'https://login.microsoftonline.com/tenant-id/v2.0'; + process.env.AUDIENCE = 'api://client-id'; + process.env.JWKS_URI = 'https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys'; + + mockLoadSecretsFromKeyVault.mockResolvedValue({}); + + const config = await loadConfig(); + + expect(() => validateConfig(config)).not.toThrow(); + expect(config.sfAccessToken).toBe('scratch-access-token'); + expect(config.sfInstanceUrl).toBe('https://scratch.example.my.salesforce.com'); + }); + }); + describe('SFDX Auth URL Configuration', () => { it('should load sfdxAuthUrl from environment variable', async () => { process.env.SFDX_AUTH_URL = 'force://PlatformCLI::refresh-token@test.salesforce.com'; diff --git a/test/e2e.scratch-org.test.ts b/test/e2e.scratch-org.test.ts new file mode 100644 index 0000000..37b42cb --- /dev/null +++ b/test/e2e.scratch-org.test.ts @@ -0,0 +1,82 @@ +import { execFile } from 'child_process'; +import { createRecord, getScratchOrgInfo } from '../e2e/utils/scratch-org'; + +jest.mock('child_process', () => ({ + exec: jest.fn(), + execFile: jest.fn(), +})); + +const mockedExecFile = execFile as jest.MockedFunction; + +describe('E2E scratch org utilities', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + SF_INSTANCE_URL: 'https://scratch.example.my.salesforce.com/', + SF_ACCESS_TOKEN: '00Dxx!token$with-special-chars', + SF_USERNAME: 'test-user@example.com', + SF_ORG_ID: '00Dxx0000000001', + }; + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should prefer complete scratch org credentials from environment', async () => { + const orgInfo = await getScratchOrgInfo(); + + expect(orgInfo).toEqual({ + instanceUrl: 'https://scratch.example.my.salesforce.com', + accessToken: '00Dxx!token$with-special-chars', + username: 'test-user@example.com', + orgId: '00Dxx0000000001', + }); + }); + + it('should create ContentVersion records through Salesforce CLI REST auth', async () => { + mockedExecFile.mockImplementationOnce((( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ) => { + callback(null, JSON.stringify({ + success: true, + id: '068xx0000000001AAA', + }), ''); + return {} as ReturnType; + }) as typeof execFile); + + const recordId = await createRecord('ContentVersion', { + Title: 'Template', + PathOnClient: 'template.docx', + VersionData: 'base64-content', + }); + + expect(recordId).toBe('068xx0000000001AAA'); + expect(mockedExecFile).toHaveBeenCalledWith( + 'sf', + expect.arrayContaining([ + 'api', + 'request', + 'rest', + '/services/data/v65.0/sobjects/ContentVersion', + '--method', + 'POST', + '--target-org', + 'test-user@example.com', + ]), + expect.objectContaining({ + env: expect.not.objectContaining({ + SF_ACCESS_TOKEN: expect.any(String), + SF_INSTANCE_URL: expect.any(String), + }), + }), + expect.any(Function) + ); + }); +}); diff --git a/test/generate.integration.test.ts b/test/generate.integration.test.ts index 6577f7f..a9310cf 100644 --- a/test/generate.integration.test.ts +++ b/test/generate.integration.test.ts @@ -283,8 +283,12 @@ describeIntegration('POST /generate - Integration Tests with Real Salesforce', ( expect(pdfResult.records).toHaveLength(1); expect(pdfResult.records[0].FileExtension).toBe('pdf'); - // There should also be a DOCX file created around the same time - // Note: We can't easily verify this without tracking both IDs in the response + expect(body.docxContentVersionId).toMatch(/^[a-zA-Z0-9]{18}$/); + const docxQuery = `SELECT Id, Title, FileExtension FROM ContentVersion WHERE Id = '${body.docxContentVersionId}' LIMIT 1`; + const docxResult = await sfApi.get(`/services/data/v59.0/query?q=${encodeURIComponent(docxQuery)}`); + expect(docxResult.records).toHaveLength(1); + expect(docxResult.records[0].FileExtension).toBe('docx'); + console.log('Generated PDF with stored DOCX:', body.downloadUrl); }); @@ -704,4 +708,4 @@ describeIntegration('POST /generate - Integration Tests with Real Salesforce', ( } }); }); -}); \ No newline at end of file +}); diff --git a/test/generate.unit.test.ts b/test/generate.unit.test.ts index 31f569a..6a5c808 100644 --- a/test/generate.unit.test.ts +++ b/test/generate.unit.test.ts @@ -249,6 +249,90 @@ describe('POST /generate - Unit Tests with Mocked Dependencies', () => { expect(body.contentVersionId).toBe(testContentVersionId); }); + it('should return stored merged DOCX ContentVersion ID when requested', async () => { + const testTemplateId = '068000000000105AAA'; + const pdfContentVersionId = '068000000000106AAA'; + const pdfContentDocumentId = '069000000000106AAA'; + const docxContentVersionId = '068000000000107AAA'; + const docxContentDocumentId = '069000000000107AAA'; + const testDocxBuffer = await createTestDocxBuffer(); + + nock('https://test.salesforce.com') + .get(`/services/data/v59.0/sobjects/ContentVersion/${testTemplateId}/VersionData`) + .reply(200, testDocxBuffer); + + nock('https://test.salesforce.com') + .post('/services/data/v59.0/sobjects/ContentVersion', (body) => { + expect(body.PathOnClient).toBe('test-output.pdf'); + return true; + }) + .reply(201, { + id: pdfContentVersionId, + success: true, + errors: [], + }); + + nock('https://test.salesforce.com') + .get(`/services/data/v59.0/query`) + .query((query) => typeof query.q === 'string' && query.q.includes(pdfContentVersionId)) + .reply(200, { + records: [{ + ContentDocumentId: pdfContentDocumentId, + }], + }); + + nock('https://test.salesforce.com') + .post('/services/data/v59.0/sobjects/ContentVersion', (body) => { + expect(body.PathOnClient).toBe('test-output.docx'); + return true; + }) + .reply(201, { + id: docxContentVersionId, + success: true, + errors: [], + }); + + nock('https://test.salesforce.com') + .get(`/services/data/v59.0/query`) + .query((query) => typeof query.q === 'string' && query.q.includes(docxContentVersionId)) + .reply(200, { + records: [{ + ContentDocumentId: docxContentDocumentId, + }], + }); + + const request: DocgenRequest = { + templateId: testTemplateId, + outputFileName: 'test-output.pdf', + outputFormat: 'PDF', + locale: 'en-US', + timezone: 'America/New_York', + options: { + storeMergedDocx: true, + returnDocxToBrowser: false, + }, + data: { + Account: { Name: 'Test Account' }, + GeneratedDate__formatted: '2 Jun 2026', + }, + }; + + const response = await app.inject({ + method: 'POST', + url: '/generate', + payload: request, + }); + + if (response.statusCode !== 200) { + console.log('Response body:', response.body); + } + expect(response.statusCode).toBe(200); + + const body: DocgenResponse = JSON.parse(response.body); + expect(body.contentVersionId).toBe(pdfContentVersionId); + expect(body.docxContentVersionId).toBe(docxContentVersionId); + }); + it('should successfully generate a PPTX document', async () => { const testTemplateId = '068000000000103AAA'; const testContentVersionId = '068000000000104AAA'; diff --git a/test/sf.auth.test.ts b/test/sf.auth.test.ts index 7a690f8..1bece29 100644 --- a/test/sf.auth.test.ts +++ b/test/sf.auth.test.ts @@ -332,6 +332,59 @@ describe('Salesforce JWT Bearer Authentication', () => { }); }); +describe('Salesforce Direct Access Token Authentication', () => { + beforeEach(() => { + nock.cleanAll(); + resetSalesforceAuth(); + }); + + afterEach(() => { + nock.cleanAll(); + resetSalesforceAuth(); + }); + + it('should accept a direct access token and instance URL', async () => { + const auth = new SalesforceAuth({ + sfAccessToken: 'scratch-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com/', + }); + + await expect(auth.getAccessToken()).resolves.toBe('scratch-access-token'); + expect(auth.getInstanceUrl()).toBe('https://scratch.example.my.salesforce.com'); + }); + + it('should cache a direct access token without calling a token endpoint', async () => { + const auth = new SalesforceAuth({ + sfAccessToken: 'scratch-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com', + }); + + const token1 = await auth.getAccessToken(); + const token2 = await auth.getAccessToken(); + + expect(token1).toBe('scratch-access-token'); + expect(token2).toBe(token1); + expect(nock.pendingMocks()).toHaveLength(0); + }); + + it('should prefer direct access token auth over invalid SFDX Auth URL', async () => { + const auth = new SalesforceAuth({ + sfAccessToken: 'scratch-access-token', + sfInstanceUrl: 'https://scratch.example.my.salesforce.com', + sfdxAuthUrl: 'not-a-force-url', + }); + + await expect(auth.getAccessToken()).resolves.toBe('scratch-access-token'); + expect(auth.getInstanceUrl()).toBe('https://scratch.example.my.salesforce.com'); + }); + + it('should reject incomplete direct access token config without another auth method', () => { + expect(() => new SalesforceAuth({ + sfAccessToken: 'scratch-access-token', + })).toThrow(/requires either/i); + }); +}); + describe('Salesforce SFDX Auth URL Authentication', () => { let auth: SalesforceAuth; @@ -396,13 +449,6 @@ describe('Salesforce SFDX Auth URL Authentication', () => { expect(() => new SalesforceAuth(config)).toThrow(/Invalid (format|SFDX Auth URL|Missing required components)/i); }); - it('should handle SFDX Auth URL with special characters in refresh token', () => { - const sfdxAuthUrl = 'force://PlatformCLI::5Aep861!@#$%^&*()_+token@test.salesforce.com'; - const config = { sfdxAuthUrl }; - - expect(() => new SalesforceAuth(config)).not.toThrow(); - }); - it('should strip trailing slash from instance URL', () => { const sfdxAuthUrl = 'force://PlatformCLI::token@test.salesforce.com/'; const config = { sfdxAuthUrl }; @@ -410,6 +456,14 @@ describe('Salesforce SFDX Auth URL Authentication', () => { // Should parse successfully (trailing slash removed internally) expect(() => new SalesforceAuth(config)).not.toThrow(); }); + + it('should normalize protocol from instance URL', () => { + const authWithProtocol = new SalesforceAuth({ + sfdxAuthUrl: 'force://PlatformCLI::token@https://test.salesforce.com/', + }); + + expect(authWithProtocol.getInstanceUrl()).toBe('https://test.salesforce.com'); + }); }); describe('Refresh Token Flow', () => { @@ -474,6 +528,27 @@ describe('Salesforce SFDX Auth URL Authentication', () => { expect(requestBody.client_secret).toBe('my-secret'); }); + it('should preserve @ and : characters in refresh token', async () => { + const refreshToken = '5Aep861!@#$%^&*()_+token:segment'; + const authWithSpecialToken = new SalesforceAuth({ + sfdxAuthUrl: `force://PlatformCLI::${refreshToken}@test.salesforce.com`, + }); + let requestBody: any; + + nock('https://test.salesforce.com') + .post('/services/oauth2/token', (body) => { + requestBody = body; + return true; + }) + .reply(200, MOCK_REFRESH_TOKEN_RESPONSE); + + await authWithSpecialToken.getAccessToken(); + + expect(requestBody.client_id).toBe('PlatformCLI'); + expect(requestBody.refresh_token).toBe(refreshToken); + expect(requestBody.client_secret).toBeUndefined(); + }); + it('should cache tokens from refresh token flow', async () => { const tokenScope = nock('https://test.salesforce.com') .post('/services/oauth2/token') diff --git a/test/worker/poller.test.ts b/test/worker/poller.test.ts index ad75911..94922df 100644 --- a/test/worker/poller.test.ts +++ b/test/worker/poller.test.ts @@ -18,6 +18,11 @@ jest.mock('../../src/convert/soffice', () => { config(); process.env.SFDX_AUTH_URL = 'force://PlatformCLI::refresh-token@test.salesforce.com'; +delete process.env.SF_DOMAIN; +delete process.env.SF_USERNAME; +delete process.env.SF_CLIENT_ID; +delete process.env.SF_PRIVATE_KEY; +delete process.env.SF_PRIVATE_KEY_PATH; // Mock logger to suppress output during tests jest.mock('pino', () => {