diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..088b731 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,36 @@ +name: Integration Tests + +on: + workflow_dispatch: + +jobs: + integration-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker + uses: docker/setup-buildx-action@v3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Run integration tests + env: + RUN_INTEGRATION_TESTS: true + SKIP_UNIT_TESTS: false + SKIP_TEARDOWN: false + REUSE_CONTAINER: false + NODE_ENV: test + FUSIONAUTH_TELEMETRY: false + run: node ./__tests__/test.js + timeout-minutes: 15 diff --git a/README.md b/README.md index 7df8398..cd79083 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ Fake user generation - `fusionauth kickstart:start` - Run in the directory of a FusionAuth Docker image to run the image - `fusionauth kickstart:stop` - Run in the directory of a FusionAuth Docker image to stop the image - `fusionauth kickstart:kill` - Run in the directory of a FusionAuth Docker image to shutdown and wipe the FusionAuth instance +- Apply Configuration + - `fusionauth apply --file ` - Apply a kickstart configuration file to a FusionAuth instance. Supports additional variable substitution with the following patterns: + - `#{DEFAULT_TENANT_ID()}` - Fetch the default tenant ID from the FusionAuth instance + - `#{ENV.VARIABLE_NAME}` - Access environment variables + - `#{PROMPT('message')}` - Prompt user for input (displays value in console) + - `#{PROMPT_HIDDEN('message')}` - Prompt user for input (hides value, suitable for passwords) - Lambdas - `fusionauth lambda:update` - Update a lambda on a FusionAuth server. - `fusionauth lambda:delete` - Delete a lambda from a FusionAuth server. diff --git a/__tests__/integration/README.md b/__tests__/integration/README.md new file mode 100644 index 0000000..c01a438 --- /dev/null +++ b/__tests__/integration/README.md @@ -0,0 +1,148 @@ +# Integration Tests + +This directory contains integration tests that verify the `apply` command works end-to-end with a running FusionAuth instance. + +## Prerequisites + +- Docker and Docker Compose must be installed and running +- Node.js 18+ +- Port 9011 must be available (FusionAuth default port) + +## Running Integration Tests + +```bash +npm run test:integration +``` + +### Run Only Unit Tests (excludes integration) + +```bash +RUN_INTEGRATION_TESTS=false SKIP_UNIT_TESTS=false NODE_ENV=test npm test +``` + +### Run Only Integration Tests + +```bash +RUN_INTEGRATION_TESTS=true SKIP_UNIT_TESTS=true NODE_ENV=test node __tests__/test.js +``` + +### Environment Variables + +- `NODE_ENV=test` — Required for test mode (prevents `process.exit()` calls in executeAction) +- `RUN_INTEGRATION_TESTS=true` — Enable integration tests +- `SKIP_UNIT_TESTS=true|false` — Control whether unit tests run +- `SKIP_TEARDOWN=true` — Keep FusionAuth container running after tests (useful for debugging) +- `REUSE_CONTAINER=true` — Reuse existing FusionAuth container instead of creating new one +- `FUSIONAUTH_TELEMETRY=false` — Disable telemetry in tests + +## What Integration Tests Cover + +Currently, the integration tests verify the following scenario: + +1. **POC Kickstart Configuration** — Applies a complete kickstart configuration that: + - Configures SMTP email settings (host, port, security, default from email, username) + - Creates an admin user with application registration and admin role + - Validates all settings are properly persisted in the FusionAuth instance + +## How It Works + +1. `setup.js` — Manages FusionAuth container lifecycle: + - Loads docker-compose from `fixtures/kickstarts/fusionauth-integration-test-base/` + - Starts PostgreSQL, OpenSearch, and FusionAuth containers + - FusionAuth auto-loads `fusionauth-integration-test-base/kickstart.json` on startup (creates API key) + - Waits for health checks + - Provides utilities for API requests using native Node.js fetch + +2. `apply/apply.integration.test.js` — Executes the actual tests: + - Uses `poc/kickstart.json` fixture to apply complete FusionAuth configuration + - Initializes variable substitution with dynamic variables + - Makes API requests to running FusionAuth instance + - Verifies SMTP configuration via tenant API query + - Verifies user creation and application registration via user API query + - Uses hardcoded POC test IDs: `appId: '3c219e58-ed0e-4b18-ad48-f4f92793ae32'`, `tenantId: '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1'` + +3. `fixtures/kickstarts/` — Test configurations: + - `poc/kickstart.json` — Complete POC configuration with SMTP settings and admin user + - `fusionauth-integration-test-base/docker-compose.yml` — Docker Compose for test environment + - `fusionauth-integration-test-base/kickstart.json` — Auto-loads API key on container startup + +## Docker Setup + +The integration tests use a self-contained Docker setup located in `fixtures/kickstarts/fusionauth-integration-test-base/`: + +- **docker-compose.yml** — Defines PostgreSQL, OpenSearch, and FusionAuth services +- **kickstart.json** — Auto-loads on FusionAuth startup (via `FUSIONAUTH_APP_KICKSTART_FILE`) +- **.env.test** — Generated at runtime with database credentials + +The FusionAuth container will automatically: +1. Initialize PostgreSQL database +2. Configure search engine (OpenSearch) +3. Load the API key from `kickstart.json` +4. Be ready to accept requests on `http://localhost:9011` + +## Test Architecture + +The integration tests use a three-layer architecture: + +1. **Layer 1: CLI Wrapper** (`action()`) — Command-line interface entry point +2. **Layer 2: Action Handler** (`executeAction()`) — Returns `{success: boolean, error?: string, results?: any}` without calling `process.exit()` +3. **Layer 3: Core Logic** (`executeKickstart()`) — Handles kickstart file parsing and API request execution + +This architecture allows tests to run in Node.js test runner while preserving CLI behavior in production mode. + +## Debugging + +If tests fail, check: + +1. **Docker availability** — Run `docker ps` and `docker-compose --version` +2. **Logs** — Check FusionAuth container logs: `docker logs fusionauth-1` (or check exact container name with `docker ps`) +3. **Port conflicts** — Ensure port 9011 is not in use +4. **Network issues** — Check Docker network connectivity +5. **NODE_ENV** — Always set `NODE_ENV=test` when running tests to prevent `process.exit()` calls +6. **Container reuse** — Use `SKIP_TEARDOWN=true REUSE_CONTAINER=true` to keep containers running between test runs for faster iteration + +## Troubleshooting + +### Container won't start + +Clear previous containers and volumes: +```bash +cd __tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base +docker-compose down -v +cd - && npm run test:integration +``` + +### Tests timeout waiting for FusionAuth + +FusionAuth container can take 30-60 seconds to start. Increase the `HEALTH_CHECK_TIMEOUT` in `setup.js` if needed (default: 120000ms). + +### Tests fail with NODE_ENV errors + +Always set `NODE_ENV=test` when running tests. This prevents `executeAction()` from calling `process.exit()`: +```bash +NODE_ENV=test RUN_INTEGRATION_TESTS=true npm test +``` + +### API requests fail with 401 or authentication errors + +The API key is generated automatically by the FusionAuth container from `kickstart.json`. Ensure: +1. The container is fully started (wait for health check) +2. The API key in `setup.js` matches the generated key: `'90dd6b25-d1ef-4175-9656-159dd994932e'` +3. The FusionAuth container has fully initialized (check logs with `docker logs fusionauth-1`) + +### SMTP configuration not persisted + +Verify that: +1. The PATCH request to `/api/tenant/{tenantId}` completes successfully +2. The tenantId in the test matches the actual FusionAuth default tenant ID +3. The SMTP configuration in `poc/kickstart.json` is valid + +### Port 9011 already in use + +Kill the process using port 9011: +```bash +lsof -i :9011 +kill -9 +``` + +Or use a different port by modifying `setup.js`. diff --git a/__tests__/integration/apply/apply.integration.test.js b/__tests__/integration/apply/apply.integration.test.js new file mode 100644 index 0000000..be22b5d --- /dev/null +++ b/__tests__/integration/apply/apply.integration.test.js @@ -0,0 +1,155 @@ +import { describe, test, before, after } from "node:test" + +import assert from "node:assert" +import { + startFusionAuthContainer, + stopFusionAuthContainer, + getUser, + getTenant, + getEmailTemplateByName, + getMessageTemplateByName +} from "../setup.js" +import { executeAction } from "../../../dist/commands/apply.js" + +/** + * Display kickstart execution errors and warnings to console + */ +function displayKickstartErrors(result) { + console.log('❌ Apply failed:', result.error) + + if (result.results?.steps) { + console.log('\n📋 Kickstart execution details:') + for (const step of result.results.steps) { + if (step.status === 'error' || step.status === 'warning') { + console.log(`\n Step: ${step.id} (${step.action} ${step.request?.url})`) + console.log(` Status: ${step.status.toUpperCase()}`) + console.log(` Response Status: ${step.response?.status}`) + + if (step.error?.message) { + console.log(` Message: ${step.error.message}`) + } + + if (step.response?.body?.fieldErrors) { + console.log(' Field Errors:') + for (const [field, fieldErrs] of Object.entries(step.response.body.fieldErrors)) { + for (const err of fieldErrs) { + console.log(` - ${field}: ${err.message}`) + } + } + } + + if (step.response?.body?.generalErrors && step.response.body.generalErrors.length > 0) { + console.log(' General Errors:') + for (const err of step.response.body.generalErrors) { + console.log(` - ${err.message || err}`) + } + } + } + } + } +} + +export function applyIntegration() { + let fusionAuthUrl + let apiKey + + // Test configuration + const appId = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' + const tenantId = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' + + // Static execute action options for POC + const pocExecuteActionOptionsStatic = { + file: '__tests__/integration/fixtures/kickstarts/poc/kickstart.json', + quiet: true, + continueOnError: false + } + + // Expected values for SMTP configuration + const expectedSmtp = { + host: 'smtp.sendgrid.net', + port: 587, + security: 'TLS', + defaultFromEmail: 'poc@fusionauth.io' + } + + // Expected admin user properties + const expectedAdminUser = { + email: 'admin@example.com', + active: true, + shouldHaveAdminRole: true + } + + before(async () => { + const container = await startFusionAuthContainer() + fusionAuthUrl = container.url + apiKey = container.apiKey + }) + + after(async () => { + await stopFusionAuthContainer() + }) + + describe('Apply Command Integration Tests', () => { + test('should properly process poc/kickstart.json test file.', async (t) => { + // Merge static and dynamic options + const pocExecuteActionOptions = { + ...pocExecuteActionOptionsStatic, + host: fusionAuthUrl, + key: apiKey + } + + const result = await executeAction(pocExecuteActionOptions) + + if (!result.success) { + displayKickstartErrors(result) + throw new Error(`Apply action failed: ${result.error}`) + } + + // Verify SMTP configuration was properly persisted in test instance + const tenant = await getTenant(tenantId, apiKey) + + assert(tenant.emailConfiguration, 'Tenant should have email configuration') + assert.equal(tenant.emailConfiguration.host, expectedSmtp.host, `SMTP host should be correctly set to ${expectedSmtp.host}`) + assert.equal(tenant.emailConfiguration.port, expectedSmtp.port, `SMTP port should be correctly set to ${expectedSmtp.port}`) + assert.equal(tenant.emailConfiguration.security, expectedSmtp.security, `SMTP security should be correctly set to ${expectedSmtp.security}`) + assert.equal(tenant.emailConfiguration.defaultFromEmail, expectedSmtp.defaultFromEmail, `Default from email should be correctly set to ${expectedSmtp.defaultFromEmail}`) + assert(tenant.emailConfiguration.username !== undefined && tenant.emailConfiguration.username !== null, 'SMTP username should be configured') + + // Verify admin user was created with correct properties + const retrievedUser = await getUser(expectedAdminUser.email, apiKey) + assert.equal(retrievedUser.email, expectedAdminUser.email, `Admin user email should be set to ${expectedAdminUser.email}`) + assert(retrievedUser.active === expectedAdminUser.active, `Admin user should be active`) + assert(retrievedUser.registrations, 'Admin user should have application registrations') + + const adminRegistration = retrievedUser.registrations.find(r => r.applicationId === appId) + assert(adminRegistration, 'Admin user should be registered to the created application') + assert(adminRegistration.roles && adminRegistration.roles.includes('admin'), 'Admin user should have admin role for the application') + + // Verify email templates were created + const setupPasswordTemplate = await getEmailTemplateByName('Set up Password', apiKey) + assert(setupPasswordTemplate, 'Set up Password email template should exist') + assert(setupPasswordTemplate.defaultHtmlTemplate, 'Set up Password template should have HTML content') + assert(setupPasswordTemplate.defaultTextTemplate, 'Set up Password template should have text content') + + const twoFactorTemplate = await getEmailTemplateByName('Two Factor Authentication', apiKey) + assert(twoFactorTemplate, 'Two Factor Authentication email template should exist') + assert(twoFactorTemplate.defaultHtmlTemplate, 'Two Factor Authentication template should have HTML content') + assert(twoFactorTemplate.defaultTextTemplate, 'Two Factor Authentication template should have text content') + + // Verify message template was created + const voiceTwoFactorTemplate = await getMessageTemplateByName('Default Voice Two Factor Request', apiKey) + assert(voiceTwoFactorTemplate, 'Default Voice Two Factor Request message template should exist') + assert.equal(voiceTwoFactorTemplate.type, 'Voice', 'Voice template should have type Voice') + assert(voiceTwoFactorTemplate.defaultTemplate, 'Voice Two Factor Request template should have default content') + + // Verify forgot password templates are configured in tenant + assert(tenant.emailConfiguration, 'Tenant should have email configuration') + assert(tenant.emailConfiguration.forgotPasswordEmailTemplateId, 'Tenant should have forgot password email template configured') + assert(tenant.emailConfiguration.verificationEmailTemplateId, 'Tenant should have verification email template configured') + + assert(tenant.phoneConfiguration, 'Tenant should have Phone configuration') + assert(tenant.phoneConfiguration.verificationTemplateId, 'Tenant should have forgot password Phone template configured') + + }) + }) +} diff --git a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/.env.test b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/.env.test new file mode 100644 index 0000000..a6e8644 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/.env.test @@ -0,0 +1,10 @@ +DATABASE_PASSWORD=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_USER=postgres +DATABASE_USERNAME=fusionauth +DATABASE_URL=jdbc:postgresql://db:5432/fusionauth +FUSIONAUTH_APP_MEMORY=512M +FUSIONAUTH_APP_RUNTIME_MODE=development +FUSIONAUTH_APP_KICKSTART_FILE=/usr/local/fusionauth/kickstart/kickstart.json +KICKSTART_FILE_PATH=/Users/mark.robustelli/Projects/fusionauth-node-cli/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/kickstart.json +OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml new file mode 100644 index 0000000..6ac70fb --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml @@ -0,0 +1,87 @@ +services: + db: + image: postgres:16.0-bookworm + environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - db_net + restart: unless-stopped + volumes: + - db_data:/var/lib/postgresql/data + + search: + image: opensearchproject/opensearch:2.11.0 + environment: + cluster.name: fusionauth + discovery.type: single-node + node.name: search + plugins.security.disabled: "true" + bootstrap.memory_lock: "true" + OPENSEARCH_JAVA_OPTS: ${OPENSEARCH_JAVA_OPTS} + healthcheck: + interval: 10s + retries: 80 + test: curl --write-out 'HTTP %{http_code}' --fail --silent --output /dev/null http://localhost:9200/ + restart: unless-stopped + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + ports: + - 9200:9200 # REST API + - 9600:9600 # Performance Analyzer + volumes: + - search_data:/usr/share/opensearch/data + networks: + - search_net + + fusionauth: + image: fusionauth/fusionauth-app:latest + depends_on: + db: + condition: service_healthy + search: + condition: service_healthy + environment: + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + DATABASE_ROOT_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_ROOT_USERNAME: ${POSTGRES_USER} + DATABASE_URL: jdbc:postgresql://db:5432/fusionauth + DATABASE_USERNAME: ${DATABASE_USERNAME} + FUSIONAUTH_APP_INSTALLATION_SOURCE: fusionauth-node-cli + FUSIONAUTH_APP_KICKSTART_FILE: ${FUSIONAUTH_APP_KICKSTART_FILE} + FUSIONAUTH_APP_MEMORY: ${FUSIONAUTH_APP_MEMORY} + FUSIONAUTH_APP_RUNTIME_MODE: ${FUSIONAUTH_APP_RUNTIME_MODE} + FUSIONAUTH_APP_URL: http://fusionauth:9011 + SEARCH_SERVERS: http://search:9200 + SEARCH_TYPE: elasticsearch + networks: + - db_net + - search_net + restart: unless-stopped + ports: + - 9011:9011 + volumes: + - fusionauth_config:/usr/local/fusionauth/config + - ./kickstart.json:/usr/local/fusionauth/kickstart/kickstart.json + +networks: + db_net: + driver: bridge + search_net: + driver: bridge + +volumes: + db_data: + fusionauth_config: + search_data: diff --git a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/kickstart.json b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/kickstart.json new file mode 100644 index 0000000..524aed0 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/kickstart.json @@ -0,0 +1,32 @@ +{ + "variables": { + "apiKey": "90dd6b25-d1ef-4175-9656-159dd994932e", + "defaultTenantId": "886a57e0-f2ac-440a-9a9d-d10c17b6f1a1", + "adminEmail": "ia@example.com", + "adminPassword": "password" + }, + "apiKeys": [ + { + "key": "#{apiKey}", + "description": "Integration Test API key" + } + ], + "requests": [ + { + "method": "POST", + "url": "/api/user/registration", + "body": { + "user": { + "email": "#{adminEmail}", + "password": "#{adminPassword}" + }, + "registration": { + "applicationId": "#{FUSIONAUTH_APPLICATION_ID}", + "roles": [ + "admin" + ] + } + } + } + ] +} diff --git a/__tests__/integration/fixtures/kickstarts/poc/kickstart.json b/__tests__/integration/fixtures/kickstarts/poc/kickstart.json new file mode 100644 index 0000000..bd8e359 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/kickstart.json @@ -0,0 +1,334 @@ +{ + "variables": { + "defaultTenantId": "#{DEFAULT_TENANT_ID()}", + "smtpPassword": "Test SMTP password", + "phoneMessengerId": "#{UUID()}", + "adminEmail": "admin@example.com", + "adminPassword": "password", + "forgotPasswordTemplateId": "#{UUID()}", + "setupPasswordTemplateId": "#{UUID()}", + "emailVerificationTemplateId": "#{UUID()}", + "registrationVerificationTemplateId": "#{UUID()}", + "passwordlessLoginTemplateId": "#{UUID()}", + "coppaNoticeTemplateId": "#{UUID()}", + "coppaNoticeReminderTemplateId": "#{UUID()}", + "breachedPasswordNotificationTemplateId": "#{UUID()}", + "twoFactorAuthenticationTemplateId": "#{UUID()}", + "twoFactorAuthenticationMethodAddedTemplateId": "#{UUID()}", + "twoFactorAuthenticationMethodRemovedTemplateId": "#{UUID()}", + "phoneForgotPasswordTemplateId": "#{UUID()}", + "phonePasswordlessLoginTemplateId": "#{UUID()}", + "phoneSetupPasswordTemplateId": "#{UUID()}", + "phoneThreatDetectedTemplateId": "#{UUID()}", + "phoneTwoFactorAddTemplateId": "#{UUID()}", + "phoneTwoFactorRemoveTemplateId": "#{UUID()}", + "phoneVerificationTemplateId": "#{UUID()}", + "voiceTwoFactorRequestTemplateId": "#{UUID()}" + }, + "requests": [ + { + "method": "POST", + "url": "/api/email/template/#{forgotPasswordTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Reset your password", + "defaultHtmlTemplate": "@{templates/emails/change-password.html.ftl}", + "defaultTextTemplate": "@{templates/emails/change-password.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Forgot Password" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{setupPasswordTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Set up your password", + "defaultHtmlTemplate": "@{templates/emails/setup-password.html.ftl}", + "defaultTextTemplate": "@{templates/emails/setup-password.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Set up Password" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{emailVerificationTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Verify your FusionAuth email address", + "defaultHtmlTemplate": "@{templates/emails/email-verification.html.ftl}", + "defaultTextTemplate": "@{templates/emails/email-verification.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Email Verification" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{registrationVerificationTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Verify your Registration", + "defaultHtmlTemplate": "@{templates/emails/registration-verification.html.ftl}", + "defaultTextTemplate": "@{templates/emails/registration-verification.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Registration Verification" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{passwordlessLoginTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Log into FusionAuth", + "defaultHtmlTemplate": "@{templates/emails/passwordless-login.html.ftl}", + "defaultTextTemplate": "@{templates/emails/passwordless-login.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Passwordless Login" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{coppaNoticeTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Notice of your consent", + "defaultHtmlTemplate": "@{templates/emails/coppa-notice.html.ftl}", + "defaultTextTemplate": "@{templates/emails/coppa-notice.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "COPPA Notice" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{coppaNoticeReminderTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Reminder: Notice of your consent", + "defaultHtmlTemplate": "@{templates/emails/coppa-email-plus-notice.html.ftl}", + "defaultTextTemplate": "@{templates/emails/coppa-email-plus-notice.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "COPPA Notice Reminder" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{breachedPasswordNotificationTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Your password is not secure", + "defaultHtmlTemplate": "@{templates/emails/breached-password.html.ftl}", + "defaultTextTemplate": "@{templates/emails/breached-password.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Breached Password Notification" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{twoFactorAuthenticationTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "Your second factor code", + "defaultHtmlTemplate": "@{templates/emails/two-factor-login.html.ftl}", + "defaultTextTemplate": "@{templates/emails/two-factor-login.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Two Factor Authentication" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{twoFactorAuthenticationMethodAddedTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "A second factor method was added", + "defaultHtmlTemplate": "@{templates/emails/two-factor-add.html.ftl}", + "defaultTextTemplate": "@{templates/emails/two-factor-add.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Two Factor Authentication Method Added" + } + } + }, + { + "method": "POST", + "url": "/api/email/template/#{twoFactorAuthenticationMethodRemovedTemplateId}", + "body": { + "emailTemplate": { + "defaultFromName": "FusionAuth POC", + "defaultSubject": "A second factor method was removed", + "defaultHtmlTemplate": "@{templates/emails/two-factor-remove.html.ftl}", + "defaultTextTemplate": "@{templates/emails/two-factor-remove.txt.ftl}", + "fromEmail": "poc@fusionauth.io", + "name": "Two Factor Authentication Method Removed" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneForgotPasswordTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-forgot-password.txt.ftl}", + "name": "Default Phone Forgot Password", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phonePasswordlessLoginTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-passwordless-login.txt.ftl}", + "name": "Default Phone Passwordless Login", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneSetupPasswordTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-setup-password.txt.ftl}", + "name": "Default Phone Set up your password", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneThreatDetectedTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-threat-detected.txt.ftl}", + "name": "Default Phone Threat Detected", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneTwoFactorAddTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-two-factor-add.txt.ftl}", + "name": "Default Phone Two Factor Add", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneTwoFactorRemoveTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-two-factor-remove.txt.ftl}", + "name": "Default Phone Two Factor Remove", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{phoneVerificationTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/phone-verification.txt.ftl}", + "name": "Default Phone Verification", + "type": "SMS" + } + } + }, + { + "method": "POST", + "url": "/api/message/template/#{voiceTwoFactorRequestTemplateId}", + "body": { + "messageTemplate": { + "defaultTemplate": "@{templates/messages/voice-two-factor-request.txt.ftl}", + "name": "Default Voice Two Factor Request", + "type": "Voice" + } + } + }, + { + "method": "POST", + "url": "/api/messenger/#{phoneMessengerId}", + "body": { + "messenger": { + "accountSID": "983C6FACEBBE4D858570FADD967A9DD7", + "authToken": "184C73BE8E44420EBAA0BA147A61B6A9", + "debug": false, + "fromPhoneNumber": "555-555-5555", + "messageTypes": ["SMS"], + "name": "My Twilio Messenger", + "type": "Twilio", + "url": "https://api.twilio.com" + } + } + }, + { + "method": "PATCH", + "url": "/api/tenant/#{defaultTenantId}", + "body": { + "tenant": { + "issuer": "http://localhost:9011", + "emailConfiguration": { + "debug": true, + "defaultFromEmail": "poc@fusionauth.io", + "defaultFromName": "FusionAuth POC", + "host": "smtp.sendgrid.net", + "username": "apikey", + "password": "#{smtpPassword}", + "port": 587, + "security": "TLS", + "verifyEmail": true, + "verificationEmailTemplateId": "#{emailVerificationTemplateId}", + "forgotPasswordEmailTemplateId": "#{forgotPasswordTemplateId}" + }, + "phoneConfiguration": { + "implicitPhoneVerificationAllowed": true, + "messengerId": "#{phoneMessengerId}", + "verificationTemplateId": "#{phoneForgotPasswordTemplateId}", + "verifyPhoneNumber": true + } + } + } + }, + { + "method": "POST", + "url": "/api/user/registration", + "body": { + "user": { + "email": "#{adminEmail}", + "password": "#{adminPassword}", + "skipVerification": true, + "firstName": "Test" + }, + "registration": { + "applicationId": "#{FUSIONAUTH_APPLICATION_ID}", + "roles": [ + "admin" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.html.ftl new file mode 100644 index 0000000..1113112 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.html.ftl @@ -0,0 +1,8 @@ +

+ To complete your login request, enter this one-time code on the login form when prompted. +

+

+ ${code} +

+ +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.txt.ftl new file mode 100644 index 0000000..63175cd --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/2fa.txt.ftl @@ -0,0 +1,5 @@ +To complete your login request, enter this one-time code on the login form when prompted. + +${code} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.html.ftl new file mode 100644 index 0000000..94b49a0 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.html.ftl @@ -0,0 +1,11 @@ +

This password was found in the list of vulnerable passwords, and is no longer secure.

+ +

In order to secure your account, it is recommended to change your password at your earliest convenience.

+ +

Follow this link to change your password.

+ + + http://localhost:9011/password/forgot?email=${user.email}&tenantId=${user.tenantId} + + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.txt.ftl new file mode 100644 index 0000000..2d83ef5 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/breached-password.txt.ftl @@ -0,0 +1,9 @@ +This password was found in the list of vulnerable passwords, and is no longer secure. + +In order to secure your account, it is recommended to change your password at your earliest convenience. + +Follow this link to change your password. + +http://localhost:9011/password/forgot?email=${user.email}&tenantId=${user.tenantId} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.html.ftl new file mode 100644 index 0000000..d1305db --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.html.ftl @@ -0,0 +1,10 @@ +[#setting url_escaping_charset="UTF-8"] +To change your password click on the following link. +

+ [#-- The optional 'state' map provided on the Forgot Password API call is exposed in the template as 'state' --] + [#assign url = "http://localhost:9011/password/change/${changePasswordId}?tenantId=${user.tenantId}" /] + [#list state!{} as key, value][#if key != "tenantId" && value??][#assign url = url + "&" + key?url + "=" + value?url/][/#if][/#list] + + ${url} +

+- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.txt.ftl new file mode 100644 index 0000000..90e9fac --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/change-password.txt.ftl @@ -0,0 +1,10 @@ +[#setting url_escaping_charset="UTF-8"] +To change your password click on the following link. + +[#-- The optional 'state' map provided on the Forgot Password API call is exposed in the template as 'state' --] +[#assign url = "http://localhost:9011/password/change/${changePasswordId}?tenantId=${user.tenantId}" /] +[#list state!{} as key, value][#if key != "tenantId" && value??][#assign url = url + "&" + key?url + "=" + value?url/][/#if][/#list] + +${url} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.html.ftl new file mode 100644 index 0000000..ef5eb4a --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.html.ftl @@ -0,0 +1,5 @@ +A while ago, you granted your child consent in our system. This email is a second notice of this consent as required by law and also to remind to that you can revoke this consent at anytime on our website or by clicking the link below: +

+ http://example.com/consent/manage +

+- FusionAuth Admin \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.txt.ftl new file mode 100644 index 0000000..594d530 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-email-plus-notice.txt.ftl @@ -0,0 +1,5 @@ +A while ago, you granted your child consent in our system. This email is a second notice of this consent as required by law and also to remind to that you can revoke this consent at anytime on our website or by clicking the link below: + +http://example.com/consent/manage + +- FusionAuth Admin \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.html.ftl new file mode 100644 index 0000000..a60208c --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.html.ftl @@ -0,0 +1,5 @@ +You recently granted your child consent in our system. This email is to notify you of this consent. If you did not grant this consent or wish to revoke this consent, click the link below: +

+ http://example.com/consent/manage +

+- FusionAuth Admin \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.txt.ftl new file mode 100644 index 0000000..ff5d448 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/coppa-notice.txt.ftl @@ -0,0 +1,5 @@ +You recently granted your child consent in our system. This email is to notify you of this consent. If you did not grant this consent or wish to revoke this consent, click the link below: + +http://example.com/consent/manage + +- FusionAuth Admin \ No newline at end of file diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.html.ftl new file mode 100644 index 0000000..85892f4 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.html.ftl @@ -0,0 +1,11 @@ +[#if user.verified] +Pro tip, your email has already been verified, but feel free to complete the verification process to verify your verification of your email address. +[/#if] + +To complete your email verification click on the following link. +

+ + http://localhost:9011/email/verify/${verificationId}?tenantId=${user.tenantId} + +

+- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.txt.ftl new file mode 100644 index 0000000..b54f2dc --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/email-verification.txt.ftl @@ -0,0 +1,9 @@ +[#if user.verified] +Pro tip, your email has already been verified, but feel free to complete the verification process to verify your verification of your email address. +[/#if] + +To complete your email verification click on the following link. + +http://localhost:9011/email/verify/${verificationId}?tenantId=${user.tenantId} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.html.ftl new file mode 100644 index 0000000..34ce130 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.html.ftl @@ -0,0 +1,10 @@ +[#setting url_escaping_charset="UTF-8"] +You have requested to log into FusionAuth using this email address. If you do not recognize this request please ignore this email. +

+ [#-- The optional 'state' map provided on the Start Passwordless API call is exposed in the template as 'state' --] + [#assign url = "http://localhost:9011/oauth2/passwordless/${code}?tenantId=${user.tenantId}" /] + [#list state!{} as key, value][#if key != "tenantId" && value??][#assign url = url + "&" + key?url + "=" + value?url/][/#if][/#list] + + ${url} +

+- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.txt.ftl new file mode 100644 index 0000000..59d4317 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/passwordless-login.txt.ftl @@ -0,0 +1,10 @@ +[#setting url_escaping_charset="UTF-8"] +You have requested to log into FusionAuth using this email address. If you do not recognize this request please ignore this email. + +[#-- The optional 'state' map provided on the Start Passwordless API call is exposed in the template as 'state' --] +[#assign url = "http://localhost:9011/oauth2/passwordless/${code}?tenantId=${user.tenantId}" /] +[#list state!{} as key, value][#if key != "tenantId" && value??][#assign url = url + "&" + key?url + "=" + value?url/][/#if][/#list] + +${url} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.html.ftl new file mode 100644 index 0000000..422bed6 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.html.ftl @@ -0,0 +1,11 @@ +[#if registration.verified] +Pro tip, your registration has already been verified, but feel free to complete the verification process to verify your verification of your registration. +[/#if] + +To complete your registration verification click on the following link. +

+ + http://localhost:9011/registration/verify/${verificationId}?tenantId=${user.tenantId} + +

+- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.txt.ftl new file mode 100644 index 0000000..9520e9f --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/registration-verification.txt.ftl @@ -0,0 +1,9 @@ +[#if registration.verified] +Pro tip, your registration has already been verified, but feel free to complete the verification process to verify your verification of your registration. +[/#if] + +To complete your registration verification click on the following link. + +http://localhost:9011/registration/verify/${verificationId}?tenantId=${user.tenantId} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.html.ftl new file mode 100644 index 0000000..6a4b334 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.html.ftl @@ -0,0 +1,7 @@ +Your account has been created and you must setup a password. Click on the following link to setup your password. +

+ + http://localhost:9011/password/change/${changePasswordId}?tenantId=${user.tenantId} + +

+- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.txt.ftl new file mode 100644 index 0000000..448ff3c --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/setup-password.txt.ftl @@ -0,0 +1,5 @@ +Your account has been created and you must setup a password. Click on the following link to setup your password. + +http://localhost:9011/password/change/${changePasswordId}?tenantId=${user.tenantId} + +- FusionAuth Admin diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.html.ftl new file mode 100644 index 0000000..c2088c4 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.html.ftl @@ -0,0 +1,3 @@ +<#include "header.html.ftl"> +

A second factor method has been added to your account.

+<#include "footer.html.ftl"> diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.txt.ftl new file mode 100644 index 0000000..0978f11 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-add.txt.ftl @@ -0,0 +1 @@ +A second factor method has been added to your account. diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.html.ftl new file mode 100644 index 0000000..40b352d --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.html.ftl @@ -0,0 +1,3 @@ +<#include "header.html.ftl"> +

Your two-factor code is: ${code}

+<#include "footer.html.ftl"> diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.txt.ftl new file mode 100644 index 0000000..169250c --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-login.txt.ftl @@ -0,0 +1 @@ +Your two-factor code is: ${code} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.html.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.html.ftl new file mode 100644 index 0000000..2a9534b --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.html.ftl @@ -0,0 +1,3 @@ +<#include "header.html.ftl"> +

A second factor method has been removed from your account.

+<#include "footer.html.ftl"> diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.txt.ftl new file mode 100644 index 0000000..91bc80e --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/emails/two-factor-remove.txt.ftl @@ -0,0 +1 @@ +A second factor method has been removed from your account. diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-forgot-password.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-forgot-password.txt.ftl new file mode 100644 index 0000000..fba63b7 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-forgot-password.txt.ftl @@ -0,0 +1 @@ +Reset your password: ${resetPasswordUrl} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-passwordless-login.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-passwordless-login.txt.ftl new file mode 100644 index 0000000..0ca63f7 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-passwordless-login.txt.ftl @@ -0,0 +1 @@ +Login link: ${loginUrl} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-setup-password.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-setup-password.txt.ftl new file mode 100644 index 0000000..94bd754 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-setup-password.txt.ftl @@ -0,0 +1 @@ +Set your password: ${resetPasswordUrl} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-threat-detected.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-threat-detected.txt.ftl new file mode 100644 index 0000000..af6d2f6 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-threat-detected.txt.ftl @@ -0,0 +1 @@ +Threat detected on your account. Review activity: ${reviewUrl} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-add.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-add.txt.ftl new file mode 100644 index 0000000..270db9e --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-add.txt.ftl @@ -0,0 +1 @@ +Two-factor method added. Code: ${code} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-remove.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-remove.txt.ftl new file mode 100644 index 0000000..102b408 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-two-factor-remove.txt.ftl @@ -0,0 +1 @@ +Two-factor method removed from your account. diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-verification.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-verification.txt.ftl new file mode 100644 index 0000000..28c75e5 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/phone-verification.txt.ftl @@ -0,0 +1 @@ +Verify your phone: ${verificationUrl} diff --git a/__tests__/integration/fixtures/kickstarts/poc/templates/messages/voice-two-factor-request.txt.ftl b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/voice-two-factor-request.txt.ftl new file mode 100644 index 0000000..38e18f8 --- /dev/null +++ b/__tests__/integration/fixtures/kickstarts/poc/templates/messages/voice-two-factor-request.txt.ftl @@ -0,0 +1 @@ +Your verification code is ${code} diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js new file mode 100644 index 0000000..2881cb2 --- /dev/null +++ b/__tests__/integration/setup.js @@ -0,0 +1,271 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { execSync, spawn } from 'node:child_process' + +/** + * Container management for integration tests + * Handles docker compose lifecycle and FusionAuth readiness checks + */ + +const FUSIONAUTH_URL = 'http://localhost:9011' +const DEFAULT_API_KEY = '90dd6b25-d1ef-4175-9656-159dd994932e' +const HEALTH_CHECK_TIMEOUT = 120000 // 2 minutes +const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds +const REQUEST_TIMEOUT = 10000 // 10 seconds + +let isContainerRunning = false + +/** + * Start FusionAuth via docker compose + * @returns {Promise<{url: string, apiKey: string}>} + */ +export async function startFusionAuthContainer() { + if (isContainerRunning || process.env.REUSE_CONTAINER === 'true') { + console.log('ℹ Using existing FusionAuth container') + return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + } + + console.log('↻ Starting FusionAuth container via docker compose...') + + const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname + const envFile = path.join(composeDir, '.env.test') + const kickstartFilePath = path.join(composeDir, 'kickstart.json') + + // Create .env.test file with test configuration + const envContent = ` +DATABASE_PASSWORD=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_USER=postgres +DATABASE_USERNAME=fusionauth +DATABASE_URL=jdbc:postgresql://db:5432/fusionauth +FUSIONAUTH_APP_MEMORY=512M +FUSIONAUTH_APP_RUNTIME_MODE=development +FUSIONAUTH_APP_KICKSTART_FILE=/usr/local/fusionauth/kickstart/kickstart.json +KICKSTART_FILE_PATH=${kickstartFilePath} +OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m +`.trim() + + fs.writeFileSync(envFile, envContent) + + try { + // Check for and tear down any existing containers first + try { + const psOutput = execSync(`cd ${composeDir} && docker compose ps -q`, { stdio: 'pipe' }).toString().trim() + if (psOutput) { + console.log('⚠ Found existing FusionAuth containers, tearing them down...') + execSync(`cd ${composeDir} && docker compose down -v`, { stdio: 'pipe' }) + console.log('✓ Existing containers removed') + } + } + } catch (e) { + // Container may not exist, that's fine + } + + // Start containers + execSync(`cd ${composeDir} && docker compose --env-file .env.test up -d`, { stdio: 'pipe' }) + + // Wait for FusionAuth to be healthy + await waitForFusionAuthReady() + + isContainerRunning = true + console.log('✓ FusionAuth container started and ready') + + return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + } catch (err) { + throw new Error(`Failed to start FusionAuth container: ${err.message}`) + } +} + +/** + * Stop FusionAuth container + * @returns {Promise} + */ +export async function stopFusionAuthContainer() { + if (process.env.SKIP_TEARDOWN === 'true') { + console.log('ℹ Skipping container teardown (SKIP_TEARDOWN=true)') + console.log('ℹ Container will remain running at http://localhost:9011') + return + } + + if (!isContainerRunning) { + return + } + + console.log('↻ Stopping FusionAuth container...') + + const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname + + try { + execSync(`cd ${composeDir} && docker compose down -v`, { stdio: 'pipe' }) + isContainerRunning = false + console.log('✓ FusionAuth container stopped') + } catch (err) { + console.error(`Warning: Failed to stop container: ${err.message}`) + } +} + +/** + * Wait for FusionAuth API to be healthy + * @returns {Promise} + */ +async function waitForFusionAuthReady() { + const startTime = Date.now() + + while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const response = await fetch(`${FUSIONAUTH_URL}/api/status`, { + signal: controller.signal + }) + clearTimeout(timeoutId) + + if (response.ok) { + // Verify authenticated API requests work by fetching tenants, there was a problem with the status returning OK but the Key did not work + let authReady = false + const authStartTime = Date.now() + + while (Date.now() - authStartTime < 30000) { // 30 second timeout for auth readiness + try { + const authController = new AbortController() + const authTimeoutId = setTimeout(() => authController.abort(), 5000) + + const tenantsResponse = await fetch(`${FUSIONAUTH_URL}/api/tenant`, { + method: 'GET', + headers: { Authorization: DEFAULT_API_KEY }, + signal: authController.signal + }) + clearTimeout(authTimeoutId) + + if (tenantsResponse.ok) { + authReady = true + break + } + } catch (err) { + // Auth not ready yet, retry + } + + await sleep(HEALTH_CHECK_INTERVAL) + } + + if (authReady) { + return + } + } + } catch (err) { + // Not ready yet, retry + } + + await sleep(HEALTH_CHECK_INTERVAL) + } + + throw new Error( + `FusionAuth did not become ready within ${HEALTH_CHECK_TIMEOUT / 1000} seconds` + ) +} + +/** + * Make HTTP request to FusionAuth API + * @param {string} method - HTTP method + * @param {string} path - API path + * @param {object} data - Request body + * @param {string} apiKey - API key for authentication + * @returns {Promise} + */ +export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT_API_KEY) { + const url = `${FUSIONAUTH_URL}${path}` + const headers = { + Authorization: apiKey, + 'Content-Type': 'application/json' + } + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) + + const options = { + method, + headers, + signal: controller.signal + } + + if (data && ['POST', 'PUT', 'PATCH'].includes(method)) { + options.body = JSON.stringify(data) + } + + const response = await fetch(url, options) + clearTimeout(timeoutId) + + if (!response.ok) { + const errorBody = await response.text() + throw new Error( + `HTTP ${response.status}: ${response.statusText} - ${errorBody.substring(0, 100)}` + ) + } + + return await response.json() + } catch (err) { + if (err.name === 'AbortError') { + throw new Error(`API request timeout: ${method} ${path}`) + } + throw new Error( + `API request failed: ${method} ${path} - ${err.message}` + ) + } +} + +/** + * Get user by email from FusionAuth + * @param {string} email - User email address + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getUser(email, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', `/api/user?email=${encodeURIComponent(email)}`, null, apiKey) + return data.user +} + +/** + * Get tenant by ID from FusionAuth + * @param {string} tenantId - Tenant ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getTenant(tenantId, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', `/api/tenant/${tenantId}`, null, apiKey) + return data.tenant +} + +/** + * Get email template by name from FusionAuth + * @param {string} name - Template name + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getEmailTemplateByName(name, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', '/api/email/template', null, apiKey) + const templates = data.emailTemplates || [] + return templates.find(t => t.name === name) +} + +/** + * Get message template by name from FusionAuth + * @param {string} name - Template name + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getMessageTemplateByName(name, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', '/api/message/template', null, apiKey) + const templates = data.messageTemplates || [] + return templates.find(t => t.name === name) +} + +/** + * Sleep for specified milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/__tests__/postInstall/index.js b/__tests__/postInstall/index.js index 0d009e5..8617076 100644 --- a/__tests__/postInstall/index.js +++ b/__tests__/postInstall/index.js @@ -1,4 +1,4 @@ -import { describe, after, before, test, mock as mockit, beforeEach, afterEach } from "node:test" +import { describe, test } from "node:test" import assert from "node:assert" import { createConfig } from '../../dist/utils.js' @@ -14,77 +14,89 @@ export function postInstall() { describe('postInstall runs properly', () => { - beforeEach(() => { + test('No config creates dir', (t) => { mock({ 'dist': {}, }) - }) - - afterEach(() => { - mock.restore(); - }) - - test('No config creates dir', (t) => { - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') + try { + const configFileExists = createConfig('dist/.fa') + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') + } finally { + mock.restore() + } }) test('No dist directory, still create the directory and file', (t) => { - before(() => { - mock({ - "./": {} - }) + mock({ + "./": {} }) - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') + try { + const configFileExists = createConfig('dist/.fa') + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') + } finally { + mock.restore() + } }) test('No config creates full config file with expected types', (t) => { - const configFileExists = createConfig('dist/.fa') - const configObject = JSON.parse(readFileSync('dist/.fa/config.json')) - assert(configObject.telemetry, true, 'Default telemetry not set to true') - assert(typeof configObject.id, 'string', "ID doesn't exist or isn't a string") + mock({ + 'dist': {}, + }) + try { + const configFileExists = createConfig('dist/.fa') + const configObject = JSON.parse(readFileSync('dist/.fa/config.json')) + assert(configObject.telemetry, true, 'Default telemetry not set to true') + assert(typeof configObject.id, 'string', "ID doesn't exist or isn't a string") + } finally { + mock.restore() + } }) test('Complete config returns false', (t) => { - before(() => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true}) - } + mock({ + dist: { + '.fa': { + 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true}) } - }) - }) - assert.equal(createConfig('dist/.fa'), false, 'Postinstall did not return false properly') + } + }) + try { + assert.equal(createConfig('dist/.fa'), false, 'Postinstall did not return false properly') + } finally { + mock.restore() + } }) test('No ID in config, but telemetry false', (t) => { - before(() => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({telemetry: false}) - } + mock({ + dist: { + '.fa': { + 'config.json': JSON.stringify({telemetry: false}) } - }) - }) - createConfig('dist/.fa') - const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) - assert.equal(typeof configObject.id, 'string', 'No ID after run') - assert.equal(configObject.telemetry, false, 'Telemetry got reset') + } + }) + try { + createConfig('dist/.fa') + const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) + assert.equal(typeof configObject.id, 'string', 'No ID after run') + assert.equal(configObject.telemetry, false, 'Telemetry got reset') + } finally { + mock.restore() + } }) test('No telemetry in config, but ID', (t) => { - before(() => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb'}) - } + mock({ + dist: { + '.fa': { + 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb'}) } - }) - }) - createConfig('dist/.fa') - const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) - assert.equal(configObject.id, '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', 'ID got reset') - assert.equal(configObject.telemetry, true, 'Telemetry did not get set') + } + }) + try { + createConfig('dist/.fa') + const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) + assert.equal(configObject.id, '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', 'ID got reset') + assert.equal(configObject.telemetry, true, 'Telemetry did not get set') + } finally { + mock.restore() + } }) diff --git a/__tests__/telemetry/index.js b/__tests__/telemetry/index.js index 1a5df7b..77b8bee 100644 --- a/__tests__/telemetry/index.js +++ b/__tests__/telemetry/index.js @@ -1,4 +1,4 @@ -import test, { describe, after, before, beforeEach, afterEach } from "node:test" +import test, { describe, before, after } from "node:test" import assert from "node:assert" import fs, { readFileSync } from "node:fs" import mock from "mock-fs" @@ -12,74 +12,82 @@ import nock from 'nock' export function telemetry() { const mockedTrueConfig = { id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - telemetry: true + telemetry: true, + version: '1.0' } const mockedFalseConfig = { id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - telemetry: false + telemetry: false, + version: '1.0' } describe('telemetry runs properly', () => { test("Creates config if no config exists", (t) => { - before(() => { - mock({ - "dist": {} - }) - }) - - const updatedConfig = telemetryUpdate(true) - assert(fs.existsSync('dist/.fa/config.json'), "File wasn't created") + mock({ + "dist": {} + }) + try { + const updatedConfig = telemetryUpdate(true) + assert(fs.existsSync('dist/.fa/config.json'), "File wasn't created") + } finally { + mock.restore() + } }) test("Only changes telemetry value", () => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) + mock({ + "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) }) - - const updatedConfig = telemetryUpdate(true) - assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) + try { + const updatedConfig = telemetryUpdate(true) + assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) + } finally { + mock.restore() + } }) test("Enable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) + mock({ + "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") + try { + const actualConfig = telemetryUpdate(true) + assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") + } finally { + mock.restore() + } }) test("Disable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) + mock({ + "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") + try { + const actualConfig = telemetryUpdate(true) + assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") + } finally { + mock.restore() + } }) test("Disable full command runs properly", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) + mock({ + "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) }) - - // TODO: Add quiet flag to remove outputs - telemetryDisable.parse() - const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) - assert.equal(actualConfig.telemetry, false) + try { + telemetryDisable.parse() + const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) + assert.equal(actualConfig.telemetry, false) + } finally { + mock.restore() + } }) test("Enable full command runs properly", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) + mock({ + "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) }) - - // TODO: Add quiet flag to remove outputs - telemetryEnable.parse() - const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) - assert.equal(actualConfig.telemetry, true) + try { + telemetryEnable.parse() + const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) + assert.equal(actualConfig.telemetry, true) + } finally { + mock.restore() + } }) }) describe('tests for logEvent', () => { diff --git a/__tests__/test.js b/__tests__/test.js index 26fcbc2..13c329b 100644 --- a/__tests__/test.js +++ b/__tests__/test.js @@ -1,6 +1,20 @@ -import { postInstall } from "./postInstall/index.js"; -import { telemetry } from "./telemetry/index.js"; +(async () => { + const { postInstall } = await import("./postInstall/index.js"); + const { telemetry } = await import("./telemetry/index.js"); + const { variableSubstitution } = await import("./utilities/kickstart/variable-substitution.test.js"); + const { validator } = await import("./utilities/kickstart/validator.test.js"); + if (process.env.SKIP_UNIT_TESTS !== 'true') { + postInstall() + telemetry() + variableSubstitution() + validator() + } -postInstall() -telemetry() \ No newline at end of file + // Integration tests require Docker and FusionAuth instance + // Run with: npm run test:integration + if (process.env.RUN_INTEGRATION_TESTS === 'true') { + const { applyIntegration } = await import("./integration/apply/apply.integration.test.js"); + applyIntegration() + } +})() \ No newline at end of file diff --git a/__tests__/utilities/kickstart/validator.test.js b/__tests__/utilities/kickstart/validator.test.js new file mode 100644 index 0000000..5bc280f --- /dev/null +++ b/__tests__/utilities/kickstart/validator.test.js @@ -0,0 +1,413 @@ +import { describe, test } from "node:test" +import assert from "node:assert" +import mock from "mock-fs" +import { KickstartValidator } from "../../../dist/utilities/kickstart/validator.js" + +export function validator() { + describe('KickstartValidator', () => { + + describe('validateConfig()', () => { + test('should reject non-object config', (t) => { + const validator = new KickstartValidator() + const result = validator.validateConfig(null) + + assert.equal(result.valid, false) + assert(result.errors.length > 0) + assert.equal(result.errors[0].category, 'schema_invalid') + }) + + test('should reject config without requests', (t) => { + const validator = new KickstartValidator() + const config = { variables: { myVar: 'value' } } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.field === 'requests')) + }) + + test('should accept minimal valid config', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { + method: 'POST', + url: '/api/application', + body: { application: { name: 'Test' } } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + assert.equal(result.errors.length, 0) + }) + + test('should accept config with variables', (t) => { + const validator = new KickstartValidator() + const config = { + variables: { appId: 'app-123', tenantId: 'tenant-456' }, + requests: [ + { + method: 'POST', + url: '/api/application/#{appId}', + body: { tenantId: '#{tenantId}' } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should reject invalid variables structure', (t) => { + const validator = new KickstartValidator() + const config = { + variables: 'not-an-object', + requests: [] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.field === 'variables')) + }) + }) + + describe('validateRequestsStructure()', () => { + test('should reject empty requests array', (t) => { + const validator = new KickstartValidator() + const config = { requests: [] } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('cannot be empty'))) + }) + + test('should reject request without method', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { url: '/api/application' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('method'))) + }) + + test('should reject request without URL', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('url'))) + }) + + test('should accept valid HTTP methods', (t) => { + const validator = new KickstartValidator() + const methods = ['POST', 'PUT', 'PATCH'] + + methods.forEach(method => { + const config = { + requests: [ + { method, url: '/api/application' } + ] + } + const result = validator.validateConfig(config) + assert.equal(result.valid, true, `Method ${method} should be valid`) + }) + }) + + test('should reject invalid body type', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST', url: '/api/app', body: 'not-an-object' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('Body must be an object'))) + }) + + test('should accept optional contentType', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST', url: '/api/app', contentType: 'application/json' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should accept optional tenantId', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST', url: '/api/app', tenantId: 'tenant-123' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + }) + + describe('validateVariableReferences()', () => { + test('should detect undefined variables in body', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { + method: 'POST', + url: '/api/app', + body: { name: '#{missingName}' } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('missingName'))) + }) + + test('should allow defined variables', (t) => { + const validator = new KickstartValidator() + const config = { + variables: { appId: 'app-123' }, + requests: [ + { method: 'POST', url: '/api/app/#{appId}' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should allow default variables', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { + method: 'POST', + url: '/api/app/#{FUSIONAUTH_APPLICATION_ID}', + body: { + tenantId: '#{FUSIONAUTH_TENANT_ID}', + managerId: '#{TENANT_MANAGER_ID}' + } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should allow UUID() pattern', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST', url: '/api/app/#{UUID()}' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should allow DEFAULT_TENANT_ID() pattern', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { method: 'POST', url: '/api/app/#{DEFAULT_TENANT_ID()}' } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, true) + }) + + test('should detect multiple undefined variables', (t) => { + const validator = new KickstartValidator() + const config = { + requests: [ + { + method: 'POST', + url: '/api/app', + body: { + field1: '#{missing1}', + field2: '#{missing2}' + } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.filter(e => e.category === 'variable_not_defined').length >= 2) + }) + + test('should extract variables from nested objects', (t) => { + const validator = new KickstartValidator() + const config = { + variables: { userId: 'user-123' }, + requests: [ + { + method: 'POST', + url: '/api/user', + body: { + user: { + id: '#{userId}', + metadata: { + created: '#{undefinedVar}' + } + } + } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('undefinedVar'))) + }) + + test('should extract variables from arrays in body', (t) => { + const validator = new KickstartValidator() + const config = { + variables: { id1: 'val1' }, + requests: [ + { + method: 'POST', + url: '/api/list', + body: { + items: [ + { id: '#{id1}' }, + { id: '#{missingId}' } + ] + } + } + ] + } + const result = validator.validateConfig(config) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('missingId'))) + }) + }) + + describe('validateFileExists()', () => { + test('should report error for missing file', (t) => { + const validator = new KickstartValidator() + const result = validator.validateFileExists('/nonexistent/file.json') + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.category === 'file_not_found')) + }) + + test('should accept existing file', (t) => { + mock({ + '/test/kickstart.json': '{"requests": []}' + }) + try { + const validator = new KickstartValidator() + const result = validator.validateFileExists('/test/kickstart.json') + + assert.equal(result.valid, true) + assert.equal(result.errors.length, 0) + } finally { + mock.restore() + } + }) + + test('should report error if path is directory', (t) => { + mock({ + '/test/': {} + }) + try { + const validator = new KickstartValidator() + const result = validator.validateFileExists('/test') + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('not a file'))) + } finally { + mock.restore() + } + }) + }) + + describe('loadAndValidateJSON()', () => { + test('should return error if file not found', (t) => { + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON('/nonexistent.json') + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.category === 'file_not_found')) + }) + + test('should return error if JSON is invalid', (t) => { + mock({ + '/test/bad.json': '{ invalid json }' + }) + try { + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON('/test/bad.json') + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.category === 'schema_invalid')) + } finally { + mock.restore() + } + }) + + test('should load and parse valid JSON', (t) => { + const config = { + requests: [ + { method: 'POST', url: '/api/app' } + ] + } + mock({ + '/test/valid.json': JSON.stringify(config) + }) + try { + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON('/test/valid.json') + + assert('config' in result) + assert.equal(result.config.requests.length, 1) + assert('lineNumbers' in result) + } finally { + mock.restore() + } + }) + + test('should include line numbers in result', (t) => { + const config = { + requests: [ + { method: 'POST', url: '/api/app1' }, + { method: 'POST', url: '/api/app2' } + ] + } + mock({ + '/test/valid.json': JSON.stringify(config) + }) + try { + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON('/test/valid.json') + + assert('lineNumbers' in result) + } finally { + mock.restore() + } + }) + }) + }) +} diff --git a/__tests__/utilities/kickstart/variable-substitution.test.js b/__tests__/utilities/kickstart/variable-substitution.test.js new file mode 100644 index 0000000..09c0cfb --- /dev/null +++ b/__tests__/utilities/kickstart/variable-substitution.test.js @@ -0,0 +1,236 @@ +import { describe, test, afterEach } from "node:test" +import assert from "node:assert" +import nock from "nock" +import mock from 'mock-fs' +import { VariableSubstitutor } from "../../../dist/utilities/kickstart/variable-substitution.js" + +export function variableSubstitution() { + + describe('VariableSubstitutor', () => { + afterEach(() => { + nock.cleanAll() + }) + + describe('initialize()', () => { + test('should fetch FUSIONAUTH_APPLICATION_ID', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + + assert(resolved.has('FUSIONAUTH_APPLICATION_ID'), 'FUSIONAUTH_APPLICATION_ID not set') + assert.equal(resolved.get('FUSIONAUTH_APPLICATION_ID'), '3c219e58-ed0e-4b18-ad48-f4f92793ae32') + }) + + test('should fetch FUSIONAUTH_TENANT_ID', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + + assert(resolved.has('FUSIONAUTH_TENANT_ID'), 'FUSIONAUTH_TENANT_ID not set') + assert.equal(resolved.get('FUSIONAUTH_TENANT_ID'), '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1') + }) + + test('should fetch TENANT_MANAGER_ID', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + + assert(resolved.has('TENANT_MANAGER_ID'), 'TENANT_MANAGER_ID not set') + assert.equal(resolved.get('TENANT_MANAGER_ID'), '9ab52a6b-6abc-4aea-8f7b-525156b2ef73') + }) + }) + + describe('resolveVariables()', () => { + test('should resolve UUID() pattern', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + myId: '#{UUID()}' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const id = resolved.get('myId') + assert(id && typeof id === 'string' && id.length === 36, 'UUID not generated') + }) + + test('should resolve DEFAULT_TENANT_ID() pattern from a FusionAuth instance', async (t) => { + // Mock the FusionAuth API response + nock('http://mocktestserver') + .get('/api/application') + .reply(200, { + applications: [ + { name: 'FusionAuth', id: '3c219e58-ed0e-4b18-ad48-f4f92793ae32', tenantId: '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' } + ] + }) + + const substituter = new VariableSubstitutor() + await substituter.initializeWithDynamicVariables( + {}, + '/test/kickstart.json', + 'test-key', + 'http://mocktestserver' + ) + + const resolved = substituter.resolveVariables({}) + assert.equal(resolved.get('DEFAULT_TENANT_ID'), '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1') + }) + + test('should resolve ENV variables', (t) => { + process.env.TEST_VAR = 'test-value' + const substituter = new VariableSubstitutor() + substituter.initialize({ + envVar: '#{ENV.TEST_VAR}' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + assert.equal(resolved.get('envVar'), 'test-value') + delete process.env.TEST_VAR + }) + + test('should handle missing ENV variables', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + envVar: '#{ENV.NONEXISTENT_VAR_XYZ}' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + assert.equal(resolved.get('envVar'), '#{ENV.NONEXISTENT_VAR_XYZ}') + }) + }) + + describe('substituteInString()', () => { + test('should substitute simple variables', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + apiKey: 'secret-123' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const result = substituter.substituteInString('/api/config/#{apiKey}', resolved) + + assert.equal(result.value, '/api/config/secret-123') + assert.equal(result.success, true) + }) + + test('should substitute multiple variables in one string', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + tenant: 'tenant-123', + resource: 'users' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const result = substituter.substituteInString( + '/api/tenant/#{tenant}/#{resource}', + resolved + ) + + assert.equal(result.value, '/api/tenant/tenant-123/users') + }) + + test('should handle unresolved variables', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const result = substituter.substituteInString('Value: #{unknownVar}', resolved) + + assert.equal(result.success, false) + assert(result.errors.length > 0, 'No error reported for missing var') + }) + + test('should handle numeric type hints', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + port: 9011 + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const result = substituter.substituteInString( + 'http://localhost:#{port?number}', + resolved + ) + + assert.equal(result.value, 'http://localhost:9011') + }) + }) + + describe('substituteRequest()', () => { + test('should substitute URL and body', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + tenantId: 'tenant-123', + email: 'test@example.com' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const request = { + method: 'PATCH', + url: '/api/tenant/#{tenantId}', + body: { tenant: { admin: '#{email}' } } + } + + const result = substituter.substituteRequest(request, resolved) + assert.equal(result.request.url, '/api/tenant/tenant-123') + assert.equal(result.request.body.tenant.admin, 'test@example.com') + }) + + test('should handle JSON body substitution', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({ + templateId: 'tpl-456' + }, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const request = { + method: 'POST', + url: '/api/template', + body: { + template: { + id: '#{templateId}', + name: 'Test Template' + } + } + } + + const result = substituter.substituteRequest(request, resolved) + assert.equal(result.request.body.template.id, 'tpl-456') + assert.equal(result.request.body.template.name, 'Test Template') + }) + + test('should report errors for unresolved variables in requests', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const request = { + method: 'PATCH', + url: '/api/tenant/#{missingVar}', + body: {} + } + + const result = substituter.substituteRequest(request, resolved) + assert(result.errors.length > 0, 'Should report error for missing variable') + }) + }) + + describe('File inclusions (@{} and ${})', () => { + test('should report error for missing included file', (t) => { + const substituter = new VariableSubstitutor() + substituter.initialize({}, '/test/kickstart.json') + + const resolved = substituter.resolveVariables({}) + const result = substituter.substituteInString( + '@{nonexistent/file.ftl}', + resolved + ) + + assert(!result.success, 'Should fail for missing file') + assert(result.errors.length > 0, 'Should report error') + }) + + }) + }) +} diff --git a/src/commands/apply.ts b/src/commands/apply.ts new file mode 100644 index 0000000..6ab1ea0 --- /dev/null +++ b/src/commands/apply.ts @@ -0,0 +1,597 @@ +/** + * FusionAuth CLI Apply Command + * Reads a kickstart.json file and applies it to a FusionAuth instance + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import * as fs from 'node:fs'; +import { apiKeyOption, hostOption } from '../options.js'; +import { + ApplyOptions, + ExecutionMetrics, + StepResult, + StepStatus, + ErrorCategory, +} from '../utilities/apply/types.js'; +import { KickstartValidator } from '../utilities/kickstart/validator.js'; +import { VariableSubstitutor } from '../utilities/kickstart/variable-substitution.js'; +import { HTTPClient, StepExecutor } from '../utilities/apply/http-client.js'; +import { collectPromptedValues } from '../utilities/apply/prompts.js'; +import { logEvent } from '../utils.js'; +import * as utils from '../utils.js'; + +export const executeAction = async function (options: Record): Promise<{ success: boolean; error?: string; results?: any }> { + try { + logEvent('cli command apply'); + const kickstartResult = await executeKickstart(options); + if (kickstartResult.exitCode === 0) { + return { success: true, results: kickstartResult.results }; + } else { + return { + success: false, + error: `Kickstart execution failed with exit code ${kickstartResult.exitCode}`, + results: kickstartResult.results + }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (process.env.NODE_ENV !== 'test') { + utils.errorAndExit(chalk.red(`✖ ${message}`)); + } + return { success: false, error: message }; + } +}; + +// Wrapper for CLI command that matches Commander.js action signature (returns void) +const action = async function (options: Record): Promise { + const result = await executeAction(options); + if (!result.success) { + utils.errorAndExit(chalk.red(`✖ ${result.error}`), 1); + } +}; + +/** + * Execute the apply command + */ +async function executeKickstart(commandOptions: Record): Promise<{ exitCode: number; results: { steps: StepResult[]; metrics: ExecutionMetrics } }> { + // Extract connection and behavior options from command + const host = (commandOptions.host as string) || 'http://localhost:9011'; + const key = commandOptions.key as string; + const continueOnError = (commandOptions.continueOnError as boolean) || false; + const quiet = (commandOptions.quiet as boolean) || false; + const verbose = (commandOptions.verbose as boolean) || false; + const logFile = commandOptions.logFile as string | undefined; + + // Validate required options + if (!key) { + throw new Error(`Missing required options:\n The apply command requires an existing API Key supplied in the command`); + } + + if (!(commandOptions.file as string)) { + throw new Error(`Missing required options:\n --file is required`); + } + + const opts: ApplyOptions = { + file: commandOptions.file as string, + continueOnError, + verbose, + quiet, + logFile, + }; + + if (!quiet) { + console.log( + chalk.blue( + `\n⚙️ FusionAuth CLI - Apply\n` + ) + ); + } + + // Step 1: Load and validate kickstart file + if (!opts.quiet) { + console.log(chalk.gray('1️⃣ Loading and validating kickstart file...')); + } + + const validator = new KickstartValidator(); + const loadResult = validator.loadAndValidateJSON(opts.file); + + if ('errors' in loadResult && !loadResult.valid) { + let errorList: string[] = []; + try { + if (Array.isArray(loadResult.errors)) { + errorList = loadResult.errors + .map((e) => { + if (e && typeof e === 'object' && 'message' in e) { + return (e as { message: unknown }).message?.toString() || 'Unknown error'; + } + return String(e); + }) + .filter(Boolean); + } + } catch { + errorList = ['Failed to parse errors']; + } + + throw new Error( + `Failed to load kickstart file: ${errorList.length > 0 ? errorList.join(', ') : 'Unknown error'}` + ); + } + + const { config, lineNumbers } = loadResult as { config: unknown; lineNumbers: Record }; + const configValidation = validator.validateConfig(config); + + if (!configValidation.valid) { + let errorMessages = ''; + try { + errorMessages = configValidation.errors + .map((e) => { + // Safely handle error objects + if (e && typeof e === 'object' && 'message' in e) { + const error = e as {field?: unknown; message: unknown}; + return ` ${(error.field?.toString() || 'config')}: ${error.message?.toString() || 'Unknown'}`; + } + return ` ${String(e)}`; + }) + .join('\n'); + } catch { + errorMessages = ' Failed to parse validation errors'; + } + throw new Error( + `Invalid kickstart configuration:\n${errorMessages || ' Unknown error'}` + ); + } + + if (!opts.quiet) { + console.log(chalk.green('✓ Kickstart file validated')); + } + + // Step 2: Resolve variables + if (!opts.quiet) { + console.log(chalk.gray('2️⃣ Resolving variables...')); + } + + const substituter = new VariableSubstitutor(); + const kickstartConfig = config as { variables?: Record }; + + // Initialize with dynamic variable fetching (includes DEFAULT_TENANT_ID() support) + await substituter.initializeWithDynamicVariables( + kickstartConfig.variables || {}, + opts.file, + key, + host + ); + + const resolved = substituter.resolveVariables(kickstartConfig as never); + + // Collect prompted variables + const promptedVars = substituter.getPromptedVariables(kickstartConfig.variables || {}); + const hiddenPromptedVars = substituter.getHiddenPromptedVariables(kickstartConfig.variables || {}); + + if (promptedVars.size > 0 || hiddenPromptedVars.size > 0) { + if (!opts.quiet) { + console.log(chalk.gray('\n📋 Please provide the following values:\n')); + } + + try { + const userValues = await collectPromptedValues(promptedVars, hiddenPromptedVars); + + // Update resolved map with user-provided values + for (const [varName, userValue] of userValues) { + resolved.set(varName, userValue); + } + + if (!opts.quiet) { + console.log(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to collect prompted values: ${message}`); + } + } + + if (!opts.quiet) { + console.log( + chalk.green( + `✓ Resolved ${resolved.size} variables` + ) + ); + } + + if (opts.verbose) { + console.log(chalk.gray(' Resolved variables:')); + for (const [key, value] of resolved) { + const displayValue = typeof value === 'object' + ? JSON.stringify(value).substring(0, 50) + '...' + : String(value).substring(0, 50); + console.log(chalk.gray(` ${key}: ${displayValue}`)); + } + console.log(chalk.gray(` Checking for defaultTenantId: ${resolved.get('defaultTenantId')}`)); + } + + // Step 3: Check server connectivity + if (!quiet) { + console.log(chalk.gray('3️⃣ Checking FusionAuth server connectivity...')); + } + + const httpClient = new HTTPClient(host, key); + + try { + await httpClient.waitForServerReady(15, 2000); + if (!quiet) { + console.log(chalk.green('✓ Connected to FusionAuth')); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Cannot connect to FusionAuth: ${message}`); + } + + // Step 4: Process requests + const requests = (kickstartConfig as { requests?: unknown[] }).requests || []; + const stepExecutor = new StepExecutor(httpClient); + const metrics: ExecutionMetrics = { + totalDurationMs: 0, + startTime: new Date(), + endTime: new Date(), + stepsExecuted: 0, + stepsSucceeded: 0, + stepsFailed: 0, + stepsSkipped: 0, + stepsWarned: 0, + successRate: 0, + averageStepDurationMs: 0, + requestSizeBytes: 0, + responseSizeBytes: 0, + }; + + if (!opts.quiet) { + console.log( + chalk.gray( + `\n4️⃣ Processing ${requests.length} request(s)...\n` + ) + ); + } + + const stepResults: StepResult[] = []; + let hasErrors = false; + + for (let index = 0; index < requests.length; index++) { + const stepId = `step-${String(index + 1).padStart(5, '0')}`; + const request = requests[index] as Record; + + if (!opts.quiet) { + process.stdout.write( + chalk.gray( + ` [${index + 1}/${requests.length}] (line ${lineNumbers[index] ?? index}) ${request.method as string} ${request.url as string}...` + ) + ); + } + + // Substitute variables in request + const substituted = substituter.substituteRequest( + request as never, + resolved + ); + + if (opts.verbose && substituted.request.body) { + console.log(chalk.gray(` Request body: ${JSON.stringify(substituted.request.body, null, 2)}`)); + } + + if (substituted.errors.length > 0) { + const stepResult: StepResult = { + id: stepId, + action: request.method as string, + status: StepStatus.FAILED, + sourceLineNumber: lineNumbers[index] ?? index, + completedAt: new Date().toISOString(), + durationMs: 0, + error: { + category: ErrorCategory.INVALID_PAYLOAD, + message: substituted.errors.join('; '), + }, + }; + stepResults.push(stepResult); + + if (!opts.quiet) { + console.log(chalk.red(' ✖')); + if (opts.verbose) { + console.log(chalk.red(` Substitution errors: ${substituted.errors.join('; ')}`)); + } + } + + metrics.stepsExecuted++; + metrics.stepsFailed++; + + if (!opts.continueOnError) { + hasErrors = true; + break; + } + + continue; + } + + // Execute request + try { + const { response, durationMs } = await stepExecutor.executeStep({ + id: stepId, + index, + sourceLineNumber: lineNumbers[index] ?? index, + request: request as never, + substitutedRequest: substituted.request, + }); + + metrics.stepsExecuted++; + + if (stepExecutor.isSuccessResponse(response)) { + const stepResult: StepResult = { + id: stepId, + action: request.method as string, + status: StepStatus.SUCCESS, + sourceLineNumber: lineNumbers[index] ?? index, + request: { + method: substituted.request.method, + url: substituted.request.url, + }, + response: { + status: response.status, + contentType: response.contentType, + }, + completedAt: new Date().toISOString(), + durationMs, + }; + stepResults.push(stepResult); + + if (!opts.quiet) { + console.log(chalk.green(` ✓ (${durationMs}ms)`)); + if (opts.verbose) { + console.log(chalk.gray(` Response status: ${response.status}`)); + if (typeof response.body === 'object' && response.body !== null && Object.keys(response.body).length > 0) { + console.log(chalk.gray(` Response: ${JSON.stringify(response.body, null, 2)}`)); + } + } + } + + metrics.stepsSucceeded++; + metrics.averageStepDurationMs += durationMs; + } else { + const { category, message } = stepExecutor.extractErrorDetails(response); + + // Check if this is a duplicate/already exists warning + const responseBody = response.body as Record; + const isDuplicate = + // Check fieldErrors for [duplicate] codes + (responseBody?.fieldErrors && + typeof responseBody.fieldErrors === 'object' && + Object.values(responseBody.fieldErrors as Record).some((fieldError: unknown) => { + if (Array.isArray(fieldError)) { + return fieldError.some((e: unknown) => + typeof e === 'object' && e !== null && + ((e as Record)?.code?.toString().includes('[duplicate]') || + (e as Record)?.message?.toString().includes('already exists')) + ); + } + return false; + })) || + // Check generalErrors for [duplicate] codes + (responseBody?.generalErrors && + Array.isArray(responseBody.generalErrors) && + (responseBody.generalErrors as unknown[]).some((e: unknown) => + typeof e === 'object' && e !== null && + ((e as Record)?.code === '[duplicate]' || + (e as Record)?.message?.toString().includes('already exists')) + )) || + message.includes('[duplicate]') || + message.includes('already exists'); + + const stepResult: StepResult = { + id: stepId, + action: request.method as string, + status: isDuplicate ? StepStatus.WARNING : StepStatus.FAILED, + sourceLineNumber: lineNumbers[index] ?? index, + request: { + method: substituted.request.method, + url: substituted.request.url, + }, + response: { + status: response.status, + contentType: response.contentType, + body: response.body as Record, + }, + completedAt: new Date().toISOString(), + durationMs, + error: { + category: category as ErrorCategory, + message, + statusCode: response.status, + responseBody: response.body as Record, + }, + }; + stepResults.push(stepResult); + + if (!opts.quiet) { + if (isDuplicate) { + console.log(chalk.yellow(` ⚠ (${response.status} - duplicate)`)); + if (opts.verbose) { + console.log(chalk.yellow(` Warning: ${message}`)); + } + } else { + console.log(chalk.red(` ✖ (${response.status} ${response.statusText})`)); + if (opts.verbose) { + console.log(chalk.gray(` Error: ${message}`)); + if (typeof response.body === 'object' && response.body !== null) { + console.log(chalk.gray(` Response: ${JSON.stringify(response.body, null, 2)}`)); + } + } + } + } + + if (isDuplicate) { + metrics.stepsWarned++; + } else { + metrics.stepsFailed++; + } + + if (!opts.continueOnError) { + hasErrors = true; + break; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + const stepResult: StepResult = { + id: stepId, + action: request.method as string, + status: StepStatus.FAILED, + sourceLineNumber: lineNumbers[index] ?? index, + request: { + method: substituted.request.method, + url: substituted.request.url, + }, + completedAt: new Date().toISOString(), + durationMs: 0, + error: { + category: ErrorCategory.NETWORK_ERROR, + message, + }, + }; + stepResults.push(stepResult); + + if (!opts.quiet) { + console.log(chalk.red(` ✖ (${message})`)); + } + + metrics.stepsExecuted++; + metrics.stepsFailed++; + + if (!opts.continueOnError) { + hasErrors = true; + break; + } + } + } + + // Step 5: Summary + metrics.endTime = new Date(); + metrics.totalDurationMs = + metrics.endTime.getTime() - metrics.startTime.getTime(); + + if (metrics.stepsExecuted > 0) { + metrics.averageStepDurationMs = Math.round( + metrics.averageStepDurationMs / metrics.stepsExecuted + ); + } + + metrics.successRate = + metrics.stepsExecuted > 0 + ? Math.round((metrics.stepsSucceeded / metrics.stepsExecuted) * 100) + : 0; + + if (!opts.quiet) { + console.log(); + console.log(chalk.gray('═'.repeat(60))); + console.log( + chalk.blue( + `\n📊 Summary (${(metrics.totalDurationMs / 1000).toFixed(2)}s)\n` + ) + ); + console.log( + ` Executed: ${chalk.cyan(metrics.stepsExecuted)} | Success: ${chalk.green(metrics.stepsSucceeded)} | Warnings: ${chalk.yellow(metrics.stepsWarned)} | Failed: ${chalk.red(metrics.stepsFailed)}` + ); + + console.log(` Success Rate: ${chalk.bold(metrics.successRate)}%`); + console.log(); + } + + // Write log file if requested + if (opts.logFile) { + writeLogFile(opts.logFile, stepResults, metrics); + } + + const exitCode = hasErrors || metrics.stepsFailed > 0 ? 2 : 0; + + if (exitCode === 0) { + if (!opts.quiet) { + console.log(chalk.green('✓ Kickstart applied successfully!')); + } + } else { + if (!opts.quiet) { + console.log( + chalk.red( + `✖ Kickstart failed (${metrics.stepsFailed} error(s))` + ) + ); + } + } + + return { + exitCode, + results: { + steps: stepResults, + metrics, + } + }; +} + +/** + * Write execution results to a log file + */ +function writeLogFile( + logFilePath: string, + stepResults: StepResult[], + metrics: ExecutionMetrics +): void { + try { + const timestamp = new Date().toISOString(); + + // Determine output path + let outputPath = logFilePath; + if (!logFilePath || logFilePath.trim() === '') { + // Auto-generate filename with timestamp if no specific path given + outputPath = `kickstart-${new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]}-${Date.now()}.json`; + } + + const logData = { + timestamp, + metrics: { + totalDurationMs: metrics.totalDurationMs, + startTime: metrics.startTime, + endTime: metrics.endTime, + stepsExecuted: metrics.stepsExecuted, + stepsSucceeded: metrics.stepsSucceeded, + stepsWarned: metrics.stepsWarned, + stepsFailed: metrics.stepsFailed, + stepsSkipped: metrics.stepsSkipped, + successRate: metrics.successRate, + }, + steps: stepResults, + }; + + fs.writeFileSync(outputPath, JSON.stringify(logData, null, 2), 'utf-8'); + console.log(chalk.gray(`\n✓ Execution log written to: ${outputPath}`)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.log( + chalk.yellow(`⚠ Warning: Failed to write log file: ${message}`) + ); + } +} + +/** + * Apply Command + */ +export const applyCommand = new Command() + .command('apply') + .description('Apply a kickstart.json configuration to a FusionAuth instance') + .addOption(hostOption) + .addOption(apiKeyOption) + .option('-f, --file ', 'Path to kickstart.json file') + .option( + '-e, --continue-on-error', + 'Continue executing steps even if one fails', + false + ) + .option('-v, --verbose', 'Show detailed output including request/response', false) + .option('-q, --quiet', 'Minimize output', false) + .option('--log-file ', 'Write execution results to a log file') + .action(action); diff --git a/src/commands/index.ts b/src/commands/index.ts index 983babb..07f0f21 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,6 +5,7 @@ export * from './email-duplicate.js'; export * from './email-html-to-text.js'; export * from './email-upload.js'; export * from './email-watch.js'; +export * from './apply.js'; export * from './kickstart-install.js' export * from './kickstart-kill.js' export * from './kickstart-start.js'; diff --git a/src/index.ts b/src/index.ts index 50d066f..7b0210f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,18 @@ import { Command } from '@commander-js/extra-typings'; import chalk from 'chalk'; import figlet from 'figlet'; import * as commands from './commands/index.js'; + +// Handle unhandled promise rejections gracefully +process.on('unhandledRejection', (reason) => { + const message = reason instanceof Error ? reason.message : String(reason); + // Suppress known telemetry shutdown timeouts + if (message.includes('PostHog') || message.includes('telemetry')) { + process.exit(0); + } + console.error(chalk.red(`✖ Error: ${message}`)); + process.exit(1); +}); + const fusionString = figlet.textSync('Fusion').split('\n'); const authString = figlet.textSync('Auth').split('\n'); fusionString.forEach((line, i) => { @@ -11,5 +23,10 @@ fusionString.forEach((line, i) => { }); const program = new Command(); program.name('@fusionauth/cli').description('CLI for FusionAuth'); -Object.values(commands).forEach((command) => program.addCommand(command)); +Object.values(commands).forEach((command) => { + // Only add Command instances, skip other exports (like executeAction) + if (command instanceof Command) { + program.addCommand(command as unknown as Command); + } +}); program.parse(); diff --git a/src/utilities/apply/http-client.ts b/src/utilities/apply/http-client.ts new file mode 100644 index 0000000..df56f8c --- /dev/null +++ b/src/utilities/apply/http-client.ts @@ -0,0 +1,374 @@ +/** + * HTTP Request Execution Engine for FusionAuth CLI Apply command + * Handles HTTP communication with FusionAuth API + */ + +import { HTTPResponse, ParsedStep, TimeoutConfig } from './types.js'; + +/** + * Default timeout configuration + */ +const DEFAULT_TIMEOUTS: TimeoutConfig = { + connectTimeoutMs: 5000, + readTimeoutMs: 30000, +}; + +/** + * HTTP Client for executing kickstart requests + */ +export class HTTPClient { + private baseUrl: string; + private apiKey: string; + private timeoutConfig: TimeoutConfig; + + /** + * Initialize HTTP client + * @param baseUrl Base URL of FusionAuth instance (e.g., https://auth.example.com) + * @param apiKey API key for authorization + * @param timeoutConfig Optional timeout configuration + */ + constructor( + baseUrl: string, + apiKey: string, + timeoutConfig?: Partial + ) { + this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + this.apiKey = apiKey; + this.timeoutConfig = { + ...DEFAULT_TIMEOUTS, + ...timeoutConfig, + }; + } + + /** + * Wait for FusionAuth server to be ready + * Polls /api/status endpoint until it returns JSON response + * @param maxAttempts Maximum number of attempts (default: 30) + * @param delayMs Delay between attempts in milliseconds (default: 4000) + * @returns true if server is ready, throws error if timeout + */ + public async waitForServerReady( + maxAttempts: number = 30, + delayMs: number = 4000 + ): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await this.executeRequest( + 'GET', + '/api/status', + undefined, + undefined, + undefined, + { connectTimeoutMs: 5000, readTimeoutMs: 5000 } + ); + + // Check if response is JSON (not maintenance mode or proxy error) + const contentType = response.contentType || 'application/json'; + if ( + response.status === 200 && + contentType.toLowerCase().includes('application/json') + ) { + return true; + } + } catch (err) { + // Ignore errors, will retry + } + + // Wait before next attempt (except on last attempt) + if (attempt < maxAttempts - 1) { + await this.sleep(delayMs); + } + } + + throw new Error( + `Server failed to become ready after ${maxAttempts} attempts` + ); + } + + /** + * Execute an HTTP request to FusionAuth API + * @param method HTTP method (POST, PATCH, PUT) + * @param path API path (e.g., /api/tenant/{id}) + * @param body Request body object + * @param tenantId Optional tenant ID for X-FusionAuth-TenantId header + * @param contentType Optional content-type override + * @param customTimeouts Optional custom timeout settings + * @returns HTTPResponse with status, headers, and body + */ + public async executeRequest( + method: string, + path: string, + body?: Record, + tenantId?: string, + contentType?: string, + customTimeouts?: Partial + ): Promise { + // Ensure path starts with a slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const url = `${this.baseUrl}${normalizedPath}`; + const timeouts = { ...this.timeoutConfig, ...customTimeouts }; + + const headers = this.buildHeaders(tenantId, contentType); + const bodyStr = body ? JSON.stringify(body) : undefined; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + timeouts.readTimeoutMs + ); + + const response = await fetch(url, { + method, + headers, + body: bodyStr, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const responseHeaders = this.parseHeaders(response.headers); + const responseContentType = + response.headers.get('content-type') || 'application/json'; + const responseBody = await this.parseResponseBody(response); + + return { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + body: responseBody, + contentType: responseContentType, + }; + } catch (err) { + if (err instanceof Error) { + if (err.name === 'AbortError') { + throw new Error( + `Request timeout after ${timeouts.readTimeoutMs}ms: ${method} ${path}` + ); + } + throw new Error(`Request failed: ${err.message}`); + } + throw new Error(`Request failed: ${String(err)}`); + } + } + + /** + * Execute a DELETE request + * @param path API path + * @param tenantId Optional tenant ID + * @returns HTTPResponse + */ + public async executeDelete( + path: string, + tenantId?: string + ): Promise { + return this.executeRequest('DELETE', path, undefined, tenantId); + } + + /** + * Check if a resource exists at the given path + * @param path API path + * @param tenantId Optional tenant ID + * @returns true if resource exists (status 2xx or 3xx), false otherwise + */ + public async resourceExists( + path: string, + tenantId?: string + ): Promise { + try { + const response = await this.executeRequest( + 'GET', + path, + undefined, + tenantId, + undefined, + { readTimeoutMs: 5000 } + ); + return response.status >= 200 && response.status < 400; + } catch { + return false; + } + } + + /** + * Build request headers for API call + * @param tenantId Optional tenant ID + * @param contentType Optional content-type override + * @returns Headers object + */ + private buildHeaders( + tenantId?: string, + contentType?: string + ): Record { + const headers: Record = { + 'Authorization': this.apiKey, + 'Content-Type': contentType || 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'FusionAuth-CLI-Kickstart/1.0', + }; + + if (tenantId) { + headers['X-FusionAuth-TenantId'] = tenantId; + } + + return headers; + } + + /** + * Parse response headers into a simple object + */ + private parseHeaders(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key.toLowerCase()] = value; + }); + return result; + } + + /** + * Parse response body based on content-type + */ + private async parseResponseBody( + response: Response + ): Promise | string> { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + try { + return (await response.json()) as Record; + } catch { + return await response.text(); + } + } + + return await response.text(); + } + + /** + * Sleep for a specified number of milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Format a request for logging + */ + public formatRequest( + method: string, + path: string, + body?: Record, + tenantId?: string + ): string { + const url = `${this.baseUrl}${path}`; + const tenantInfo = tenantId ? ` [tenant: ${tenantId}]` : ''; + const bodyInfo = body ? ` (${JSON.stringify(body).length} bytes)` : ''; + return `${method} ${url}${tenantInfo}${bodyInfo}`; + } + + /** + * Format a response for logging + */ + public formatResponse(response: HTTPResponse): string { + const statusInfo = `${response.status} ${response.statusText}`; + const bodySize = + typeof response.body === 'string' + ? response.body.length + : JSON.stringify(response.body).length; + return `${statusInfo} (${bodySize} bytes)`; + } +} + +/** + * Helper class for managing request execution with step tracking + */ +export class StepExecutor { + constructor(private httpClient: HTTPClient) {} + + /** + * Execute a single step and track metrics + * @param step The parsed step to execute + * @returns Execution result with response and timing + */ + public async executeStep(step: ParsedStep): Promise<{ + response: HTTPResponse; + durationMs: number; + }> { + const startTime = Date.now(); + + const response = await this.httpClient.executeRequest( + step.substitutedRequest.method, + step.substitutedRequest.url, + step.substitutedRequest.body, + step.substitutedRequest.tenantId, + step.substitutedRequest.contentType + ); + + const durationMs = Date.now() - startTime; + + return { response, durationMs }; + } + + /** + * Check if response indicates success + */ + public isSuccessResponse(response: HTTPResponse): boolean { + return response.status >= 200 && response.status < 300; + } + + /** + * Extract error details from response + */ + public extractErrorDetails(response: HTTPResponse): { + category: string; + message: string; + } { + const statusCode = response.status; + let category = 'unknown_error'; + let message = `HTTP ${statusCode} ${response.statusText}`; + + switch (statusCode) { + case 400: + category = 'invalid_payload'; + break; + case 401: + case 403: + category = 'authentication_failed'; + break; + case 404: + category = 'not_found'; + break; + case 409: + category = 'resource_conflict'; + break; + case 500: + case 502: + case 503: + category = 'server_error'; + break; + default: + break; + } + + // Try to extract error message from response body + if (typeof response.body === 'object' && response.body !== null) { + const body = response.body as Record; + if (body.generalErrors && Array.isArray(body.generalErrors)) { + const errors = body.generalErrors as string[]; + if (errors.length > 0) { + message = errors[0]; + } + } else if (body.fieldErrors && typeof body.fieldErrors === 'object') { + const fieldErrors = body.fieldErrors as Record; + const firstField = Object.keys(fieldErrors)[0]; + if (firstField && fieldErrors[firstField]) { + message = `${firstField}: ${fieldErrors[firstField][0]}`; + } + } else if (body.message && typeof body.message === 'string') { + message = body.message; + } + } + + return { category, message }; + } +} diff --git a/src/utilities/apply/line-tracker.ts b/src/utilities/apply/line-tracker.ts new file mode 100644 index 0000000..8183527 --- /dev/null +++ b/src/utilities/apply/line-tracker.ts @@ -0,0 +1,85 @@ +/** + * Utility to track line numbers in JSON files for array elements + * Maps parsed objects back to their line numbers in the source file + */ + +import * as fs from 'node:fs'; + +/** + * Maps array indices to their starting line numbers in the JSON file + */ +export interface LineNumberMap { + [index: number]: number; +} + +/** + * Track line numbers for array elements in a JSON file + * Useful for accurate error reporting with file locations + */ +export class LineTracker { + /** + * Get line numbers for each element in a JSON array + * @param filePath Path to the JSON file + * @param arrayPath Path to the array property (e.g., 'requests') + * @returns Map of array index to starting line number (1-indexed) + */ + static getArrayLineNumbers(filePath: string, arrayPath: string): LineNumberMap { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const lineMap: LineNumberMap = {}; + + // Find the "requests" property and track array element positions + let inRequestsArray = false; + let arrayDepth = 0; + let elementIndex = 0; + let elementStartLine = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; // 1-indexed + + // Look for the "requests" array start + if (!inRequestsArray) { + if (line.includes(`"${arrayPath}"`) && line.includes('[')) { + inRequestsArray = true; + arrayDepth = 1; + // Check if there's content after the [ + const afterBracket = line.substring(line.indexOf('[') + 1).trim(); + if (afterBracket.startsWith('{')) { + elementStartLine = lineNumber; + } + } + continue; + } + + // Process lines within the requests array + if (inRequestsArray) { + // Track bracket nesting + for (let j = 0; j < line.length; j++) { + const char = line[j]; + + if (char === '{') { + if (arrayDepth === 1) { + // Start of a new array element + elementStartLine = lineNumber; + } + arrayDepth++; + } else if (char === '}') { + arrayDepth--; + if (arrayDepth === 1) { + // End of array element + lineMap[elementIndex] = elementStartLine; + elementIndex++; + elementStartLine = 0; + } else if (arrayDepth === 0) { + // End of array + return lineMap; + } + } + } + } + } + + return lineMap; + } +} diff --git a/src/utilities/apply/prompts.ts b/src/utilities/apply/prompts.ts new file mode 100644 index 0000000..c6f7feb --- /dev/null +++ b/src/utilities/apply/prompts.ts @@ -0,0 +1,116 @@ +/** + * Prompt utility for collecting user input interactively + * Handles prompting users for variable values in the apply command + */ + +import * as readline from 'node:readline'; + +/** + * Prompt the user for hidden input (e.g., password) + * Displays asterisks for each character typed but captures actual input + */ +async function promptHidden(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Use readline's built-in password-style input + const stdin = process.stdin; + const stdout = process.stdout; + + stdout.write(prompt + ' '); + + const input: string[] = []; + let charCount = 0; + + // Handle keypress events + const onData = (char: Buffer) => { + const code = char[0]; + + if (code === 13 || code === 10) { + stdin.removeListener('data', onData); + stdin.setRawMode(false); + rl.close(); + stdout.write('\n'); + resolve(input.join('')); + } + // Backspace (127 or 8) + else if (code === 127 || code === 8) { + if (input.length > 0) { + input.pop(); + charCount--; + // Move cursor back, delete character, move cursor back again + stdout.write('\x1b[1D\x1b[K'); + } + } + // Regular character + else if (code >= 32 && code <= 126) { + input.push(String.fromCharCode(code)); + charCount++; + // Backspace over the typed character, then write asterisk + stdout.write('\b*'); + } + // Ignore other control characters + }; + + stdin.setRawMode(true); + stdin.on('data', onData); + }); +} + +/** + * Prompt the user for input values + * @param promptTexts Map of variable name to prompt text (regular prompts) + * @param hiddenPromptTexts Map of variable name to prompt text (hidden prompts) + * @returns Promise resolving to map of variable name to user input + */ +export async function collectPromptedValues( + promptTexts: Map, + hiddenPromptTexts?: Map +): Promise> { + const totalPrompts = (promptTexts?.size || 0) + (hiddenPromptTexts?.size || 0); + + if (totalPrompts === 0) { + return new Map(); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const results = new Map(); + + try { + // Collect regular prompts + if (promptTexts && promptTexts.size > 0) { + for (const [varName, promptText] of promptTexts) { + const value = await new Promise((resolve) => { + rl.question(promptText + ' ', (answer) => { + resolve(answer); + }); + }); + + results.set(varName, value); + } + } + + // Close readline before handling hidden prompts to avoid interference + rl.close(); + + // Collect hidden prompts + if (hiddenPromptTexts && hiddenPromptTexts.size > 0) { + for (const [varName, promptText] of hiddenPromptTexts) { + const value = await promptHidden(promptText); + results.set(varName, value); + } + } + } catch (err) { + rl.close(); + throw err; + } + + return results; +} diff --git a/src/utilities/apply/types.ts b/src/utilities/apply/types.ts new file mode 100644 index 0000000..ef23916 --- /dev/null +++ b/src/utilities/apply/types.ts @@ -0,0 +1,192 @@ +/** + * Type definitions for the FusionAuth CLI Apply command + * Defines interfaces, enums, and error classes for the apply functionality + */ + +/** + * HTTP methods supported by the apply system + */ +export enum HTTPMethod { + PATCH = 'PATCH', + POST = 'POST', + PUT = 'PUT', +} + + +/** + * Status of a apply step execution + */ +export enum StepStatus { + FAILED = 'failed', + PENDING = 'pending', + SKIPPED = 'skipped', + SUCCESS = 'success', + WARNING = 'warning', +} + +/** + * Categories of errors that can occur during apply execution + */ +export enum ErrorCategory { + SCHEMA_INVALID = 'schema_invalid', + VARIABLE_NOT_DEFINED = 'variable_not_defined', + AUTHENTICATION_FAILED = 'authentication_failed', + NETWORK_ERROR = 'network_error', + RESOURCE_CONFLICT = 'resource_conflict', + SERVER_ERROR = 'server_error', + INVALID_PAYLOAD = 'invalid_payload', + FILE_NOT_FOUND = 'file_not_found', + UNKNOWN = 'unknown', +} + +/** + * Variable definitions that can be referenced in kickstart requests + * Values can be strings, numbers, booleans, or objects + */ +export type KickstartVariable = string | number | boolean | Record; + +/** + * Single API request to be executed as part of the kickstart + */ +export interface KickstartRequest { + method: HTTPMethod | string; + url: string; + body?: Record; + tenantId?: string; + contentType?: string; +} + +/** + * Complete kickstart configuration from kickstart.json file + */ +export interface KickstartConfig { + variables?: Record; + requests: KickstartRequest[]; + settings?: { + readTimeout?: string; + connectTimeout?: string; + }; +} + +/** + * Result of executing a single step in the kickstart + */ +export interface StepResult { + id: string; + action: HTTPMethod | string; + status: StepStatus; + sourceLineNumber?: number; + completedAt: string; + durationMs: number; + request?: { + method: string; + url: string; + }; + response?: { + status: number; + body?: Record; + contentType?: string; + }; + error?: { + category: ErrorCategory; + message: string; + statusCode?: number; + responseBody?: Record; + }; +} + +/** + * Command-line options passed to the apply command + */ +export interface ApplyOptions { + file: string; + continueOnError?: boolean; + verbose?: boolean; + quiet?: boolean; + logFile?: string; +} + +/** + * Metrics collected during apply execution + */ +export interface ExecutionMetrics { + totalDurationMs: number; + startTime: Date; + endTime: Date; + stepsExecuted: number; + stepsSucceeded: number; + stepsFailed: number; + stepsSkipped: number; + stepsWarned: number; + successRate: number; + averageStepDurationMs: number; + requestSizeBytes: number; + responseSizeBytes: number; +} + +/** + * Structured error details for apply validation or execution errors + */ +export interface ValidationError { + field?: string; + stepId?: string; + lineNumber?: number; + message: string; + category: ErrorCategory; +} + +/** + * Validation result returned by validator + */ +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: string[]; +} + +/** + * Substitution result after replacing variables + */ +export interface SubstitutionResult { + success: boolean; + value: unknown; + unresolvedVariables: string[]; + errors: string[]; +} + +/** + * HTTP response from a kickstart request + */ +export interface HTTPResponse { + status: number; + statusText: string; + headers: Record; + body: Record | string; + contentType?: string; +} + +/** + * Configuration for HTTP client timeouts + */ +export interface TimeoutConfig { + connectTimeoutMs: number; + readTimeoutMs: number; +} + +/** + * Maps request array indices to their starting line numbers in the JSON file + */ +export interface RequestLineNumbers { + [index: number]: number; +} + +/** + * Parsed step information with metadata + */ +export interface ParsedStep { + id: string; + index: number; + sourceLineNumber?: number; + request: KickstartRequest; + substitutedRequest: KickstartRequest; +} diff --git a/src/utilities/kickstart/validator.ts b/src/utilities/kickstart/validator.ts new file mode 100644 index 0000000..704ea5a --- /dev/null +++ b/src/utilities/kickstart/validator.ts @@ -0,0 +1,384 @@ +/** + * Validator module for FusionAuth CLI Kickstart command + * Handles schema validation, structure validation, and variable reference validation + */ + +import * as fs from 'node:fs'; +import { + KickstartConfig, + KickstartRequest, + ValidationResult, + ValidationError, + ErrorCategory, + HTTPMethod, + RequestLineNumbers, +} from '../apply/types.js'; +import { LineTracker } from '../apply/line-tracker.js'; + +/** + * Validates kickstart.json configuration files + */ +export class KickstartValidator { + /** + * Validate complete kickstart configuration + * @param config The kickstart configuration to validate + * @returns ValidationResult with errors if invalid + */ + public validateConfig(config: unknown): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + // Type check + if (!config || typeof config !== 'object') { + errors.push({ + message: 'Kickstart configuration must be a valid JSON object', + category: ErrorCategory.SCHEMA_INVALID, + }); + return { valid: false, errors, warnings }; + } + + const cfg = config as Record; + + // Validate variables (optional) + if (cfg.variables !== undefined) { + const variablesError = this.validateVariablesStructure(cfg.variables); + if (variablesError) { + errors.push(variablesError); + } + } + + // Validate requests (required) + if (!cfg.requests) { + errors.push({ + field: 'requests', + message: 'Kickstart configuration must include a "requests" array', + category: ErrorCategory.SCHEMA_INVALID, + }); + return { valid: false, errors, warnings }; + } + + const requestsError = this.validateRequestsStructure(cfg.requests); + if (requestsError.errors.length > 0) { + errors.push(...requestsError.errors); + warnings.push(...requestsError.warnings); + } + + // If requests are valid, check for variable references + if (errors.length === 0) { + const variableRefErrors = this.validateVariableReferences( + cfg as unknown as KickstartConfig + ); + errors.push(...variableRefErrors); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Validate that the kickstart file exists and is readable + * @param filePath Path to the kickstart file + * @returns ValidationResult + */ + public validateFileExists(filePath: string): ValidationResult { + const errors: ValidationError[] = []; + + try { + if (!fs.existsSync(filePath)) { + errors.push({ + message: `Kickstart file not found: ${filePath}`, + category: ErrorCategory.FILE_NOT_FOUND, + }); + } else if (!fs.statSync(filePath).isFile()) { + errors.push({ + message: `Path is not a file: ${filePath}`, + category: ErrorCategory.FILE_NOT_FOUND, + }); + } + } catch (err) { + errors.push({ + message: `Cannot read file: ${filePath}`, + category: ErrorCategory.FILE_NOT_FOUND, + }); + } + + return { + valid: errors.length === 0, + errors, + warnings: [], + }; + } + + /** + * Validate JSON structure of kickstart file + * @param filePath Path to the kickstart file + * @returns Parsed config with line numbers if valid, or ValidationResult with errors + */ + public loadAndValidateJSON( + filePath: string + ): { config: KickstartConfig; lineNumbers: RequestLineNumbers } | ValidationResult { + const fileError = this.validateFileExists(filePath); + if (!fileError.valid) { + return fileError; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const config = JSON.parse(content) as KickstartConfig; + const lineNumbers = LineTracker.getArrayLineNumbers(filePath, 'requests'); + return { config, lineNumbers }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + valid: false, + errors: [ + { + message: `Invalid JSON in kickstart file: ${message}`, + category: ErrorCategory.SCHEMA_INVALID, + }, + ], + warnings: [], + }; + } + } + + /** + * Validate variables object structure + */ + private validateVariablesStructure( + variables: unknown + ): ValidationError | null { + if (typeof variables !== 'object' || variables === null) { + return { + field: 'variables', + message: 'Variables must be an object', + category: ErrorCategory.SCHEMA_INVALID, + }; + } + + return null; + } + + /** + * Validate requests array structure and individual requests + */ + private validateRequestsStructure( + requests: unknown + ): { errors: ValidationError[]; warnings: string[] } { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + if (!Array.isArray(requests)) { + errors.push({ + field: 'requests', + message: 'requests must be an array', + category: ErrorCategory.SCHEMA_INVALID, + }); + return { errors, warnings }; + } + + if (requests.length === 0) { + errors.push({ + field: 'requests', + message: 'requests array cannot be empty', + category: ErrorCategory.SCHEMA_INVALID, + }); + return { errors, warnings }; + } + + requests.forEach((request, index) => { + if (typeof request !== 'object' || request === null) { + errors.push({ + field: `requests[${index + 1}]`, + message: 'Each request must be an object', + category: ErrorCategory.SCHEMA_INVALID, + }); + return; + } + + const req = request as Record; + + // Validate method + if (!req.method || typeof req.method !== 'string') { + errors.push({ + field: `requests[${index + 1}].method`, + message: 'Each request must have a "method" string property', + category: ErrorCategory.SCHEMA_INVALID, + }); + } else if ( + !Object.values(HTTPMethod).includes(req.method as HTTPMethod) + ) { + errors.push({ + field: `requests[${index + 1}].method`, + message: `Method must be one of: ${Object.values(HTTPMethod).join(', ')}. Got: ${req.method}`, + category: ErrorCategory.SCHEMA_INVALID, + }); + } + + // Validate URL + if (!req.url || typeof req.url !== 'string') { + errors.push({ + field: `requests[${index + 1}].url`, + message: 'Each request must have a "url" string property', + category: ErrorCategory.SCHEMA_INVALID, + }); + } else if (!req.url.startsWith('/api/')) { + warnings.push( + `Request ${index + 1}: URL should start with "/api/": ${req.url}` + ); + } + + // Validate body (optional but should be object if present) + if (req.body !== undefined && typeof req.body !== 'object') { + errors.push({ + field: `requests[${index + 1}].body`, + message: 'Body must be an object if provided', + category: ErrorCategory.SCHEMA_INVALID, + }); + } + + // Validate contentType (optional, should be string if present) + if ( + req.contentType !== undefined && + typeof req.contentType !== 'string' + ) { + errors.push({ + field: `requests[${index + 1}].contentType`, + message: 'contentType must be a string if provided', + category: ErrorCategory.SCHEMA_INVALID, + }); + } + + // Validate tenantId (optional, should be string if present) + if (req.tenantId !== undefined && typeof req.tenantId !== 'string') { + errors.push({ + field: `requests[${index + 1}].tenantId`, + message: 'tenantId must be a string if provided', + category: ErrorCategory.SCHEMA_INVALID, + }); + } + }); + + return { errors, warnings }; + } + + /** + * Validate that all variable references are defined + */ + private validateVariableReferences( + config: KickstartConfig + ): ValidationError[] { + const errors: ValidationError[] = []; + const definedVariables = new Set( + Object.keys(config.variables || {}) + ); + + // Add default variables that are always available + definedVariables.add('FUSIONAUTH_APPLICATION_ID'); + definedVariables.add('FUSIONAUTH_TENANT_ID'); + definedVariables.add('TENANT_MANAGER_ID'); + + // Check requests + config.requests.forEach((request, index) => { + const variableRefs = this.extractVariableReferences(request); + + variableRefs.forEach((varRef) => { + // UUID() is a special pattern + if (varRef === 'UUID()') { + return; + } + + if (!definedVariables.has(varRef)) { + errors.push({ + field: `requests[${index + 1}]`, + stepId: `step-${String(index + 1).padStart(5, '0')}`, + lineNumber: index, + message: `Undefined variable: #{${varRef}}`, + category: ErrorCategory.VARIABLE_NOT_DEFINED, + }); + } + }); + }); + + return errors; + } + + /** + * Extract variable references from a request + * Returns array of variable names (without #{}) + */ + private extractVariableReferences(request: KickstartRequest): string[] { + const refs = new Set(); + + // Check URL + refs.forEach((ref) => { + this.extractVariableReferencesFromString(request.url).forEach((r) => + refs.add(r) + ); + }); + + // Check tenantId + if (request.tenantId) { + this.extractVariableReferencesFromString(request.tenantId).forEach( + (r) => refs.add(r) + ); + } + + // Check body + if (request.body) { + this.extractVariableReferencesFromObject(request.body).forEach((r) => + refs.add(r) + ); + } + + return Array.from(refs); + } + + /** + * Extract variable references from a string + * Pattern: #{variableName} or #{UUID()} or #{FUSIONAUTH_*} + */ + private extractVariableReferencesFromString(str: string): string[] { + const pattern = /#{([^}]+)}/g; + const matches: string[] = []; + let match; + + // eslint-disable-next-line no-cond-assign + while ((match = pattern.exec(str)) !== null) { + matches.push(match[1]); + } + + return matches; + } + + /** + * Extract variable references from an object (recursively) + */ + private extractVariableReferencesFromObject( + obj: Record + ): string[] { + const refs = new Set(); + + const traverse = (value: unknown): void => { + if (typeof value === 'string') { + this.extractVariableReferencesFromString(value).forEach((r) => + refs.add(r) + ); + } else if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + value.forEach((item) => traverse(item)); + } else { + Object.values(value as Record).forEach((item) => + traverse(item) + ); + } + } + }; + + traverse(obj); + return Array.from(refs); + } +} diff --git a/src/utilities/kickstart/variable-substitution.ts b/src/utilities/kickstart/variable-substitution.ts new file mode 100644 index 0000000..efeb9c0 --- /dev/null +++ b/src/utilities/kickstart/variable-substitution.ts @@ -0,0 +1,718 @@ +/** + * Variable Substitution Engine for FusionAuth CLI Kickstart command + * Handles variable resolution, file inclusion, and template processing + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { FusionAuthClient } from '@fusionauth/typescript-client'; +import { + KickstartConfig, + KickstartRequest, + SubstitutionResult, +} from '../apply/types.js'; + +/** + * Patterns for template substitution: + * - #{variableName} or #{variableName?number} + * - #{UUID()} + * - #{DEFAULT_TENANT_ID()} - fetches the tenant ID of the "FusionAuth" application + * - #{ENV.VARNAME} + * - #{PROMPT('message')} - prompt user for input + * - #{PROMPT_HIDDEN('message')} - prompt user for input (hidden/masked) + * - @{filePath} - include file unescaped + * - ${filePath} - include file JSON-escaped + */ +export class VariableSubstitutor { + private variables: Map = new Map(); + private kickstartDir: string = process.cwd(); + private fileCache: Map = new Map(); + + // Default values for common FusionAuth variables + private static readonly DEFAULT_VARIABLES: Record = { + FUSIONAUTH_APPLICATION_ID: '3c219e58-ed0e-4b18-ad48-f4f92793ae32', + FUSIONAUTH_TENANT_ID: '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1', + TENANT_MANAGER_ID: '9ab52a6b-6abc-4aea-8f7b-525156b2ef73', + }; + + /** + * Initialize substitution engine with variables and kickstart directory + * @param variables The variables map from kickstart config + * @param kickstartFilePath Path to the kickstart.json file (used for relative file paths) + */ + public initialize( + variables: Record, + kickstartFilePath: string + ): void { + this.variables = new Map(); + this.kickstartDir = path.dirname(path.resolve(kickstartFilePath)); + this.fileCache = new Map(); + + // Add default variables first (can be overridden by explicit values) + for (const [key, value] of Object.entries( + VariableSubstitutor.DEFAULT_VARIABLES + )) { + this.variables.set(key, value); + } + + // Add provided variables (these override defaults) + for (const [key, value] of Object.entries(variables)) { + this.variables.set(key, value); + } + } + + /** + * Initialize with dynamic variable fetching from FusionAuth API + * Calls initialize() then fetches the DEFAULT_TENANT_ID from the "FusionAuth" application + * @param variables The variables map from kickstart config + * @param kickstartFilePath Path to the kickstart.json file + * @param apiKey API key for FusionAuth + * @param host Host URL of FusionAuth instance + */ + public async initializeWithDynamicVariables( + variables: Record, + kickstartFilePath: string, + apiKey: string, + host: string + ): Promise { + // First, perform standard initialization + this.initialize(variables, kickstartFilePath); + + // Fetch the DEFAULT_TENANT_ID if it's not already provided + if (!this.variables.has('DEFAULT_TENANT_ID')) { + try { + const client = new FusionAuthClient(apiKey, host); + + // Fetch all applications and find the one named "FusionAuth" + const response = await client.retrieveApplications(); + + if (!response.wasSuccessful() || !response.response.applications) { + throw new Error('Failed to retrieve applications from FusionAuth'); + } + + const fusionAuthApp = response.response.applications.find( + (app) => app.name === 'FusionAuth' + ); + + if (!fusionAuthApp) { + throw new Error( + 'Application named "FusionAuth" not found. Please ensure the application exists in your FusionAuth instance.' + ); + } + + if (!fusionAuthApp.tenantId) { + throw new Error( + 'The "FusionAuth" application does not have an associated tenant ID' + ); + } + + // Store the tenant ID + this.variables.set('DEFAULT_TENANT_ID', fusionAuthApp.tenantId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to fetch DEFAULT_TENANT_ID from FusionAuth: ${message}` + ); + } + } + } + + /** + * Resolve all variables, expanding special patterns like #{UUID()} and #{ENV.VARNAME} + * @param config The kickstart configuration + * @returns Map of resolved variables + */ + public resolveVariables(config: KickstartConfig): Map { + const resolved = new Map(); + + // First pass: add all direct variables + for (const [key, value] of this.variables.entries()) { + resolved.set(key, value); + } + + // Second pass: process special patterns + for (const [key, value] of this.variables.entries()) { + if (typeof value === 'string') { + const result = this.resolveSpecialPattern(value); + if (result.success) { + resolved.set(key, result.value); + } + } + } + + return resolved; + } + + /** + * Substitute variables and file inclusions in an object (recursively) + * @param obj Object to process (typically the request body) + * @param resolved Map of resolved variables + * @returns Substituted object + */ + public substituteInObject( + obj: unknown, + resolved: Map + ): SubstitutionResult { + const unresolvedVariables: string[] = []; + const errors: string[] = []; + + try { + const result = this.deepSubstitute(obj, resolved, unresolvedVariables); + // Convert unresolved variables to error messages + const errorMessages = unresolvedVariables.map( + (v) => `Unresolved variable: #{${v}}` + ); + return { + success: errors.length === 0 && unresolvedVariables.length === 0, + value: result, + unresolvedVariables, + errors: [...errors, ...errorMessages], + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + success: false, + value: obj, + unresolvedVariables, + errors: [message], + }; + } + } + + /** + * Substitute variables in a string + * @param str String to process + * @param resolved Map of resolved variables + * @returns Substituted string or error + */ + public substituteInString( + str: string, + resolved: Map + ): SubstitutionResult { + const unresolvedVariables: string[] = []; + const errors: string[] = []; + + try { + const result = this.substituteString(str, resolved, unresolvedVariables); + // Convert unresolved variables to error messages + const errorMessages = unresolvedVariables.map( + (v) => `Unresolved variable: #{${v}}` + ); + return { + success: errors.length === 0 && unresolvedVariables.length === 0, + value: result, + unresolvedVariables, + errors: [...errors, ...errorMessages], + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + success: false, + value: str, + unresolvedVariables, + errors: [message], + }; + } + } + + /** + * Substitute a complete kickstart request + * @param request The request to process + * @param resolved Map of resolved variables + * @returns Substituted request + */ + public substituteRequest( + request: KickstartRequest, + resolved: Map + ): { request: KickstartRequest; errors: string[] } { + const errors: string[] = []; + + // Substitute URL + const urlResult = this.substituteInString(request.url, resolved); + if (!urlResult.success) { + errors.push(`URL substitution failed: ${urlResult.errors.join(', ')}`); + } + + // Substitute tenantId if present + let tenantId = request.tenantId; + if (tenantId) { + const tenantResult = this.substituteInString(tenantId, resolved); + if (!tenantResult.success) { + errors.push( + `TenantId substitution failed: ${tenantResult.errors.join(', ')}` + ); + } else { + tenantId = tenantResult.value as string; + } + } + + // Substitute body if present + let body = request.body; + if (body) { + const bodyResult = this.substituteInObject(body, resolved); + if (!bodyResult.success) { + errors.push(`Body substitution failed: ${bodyResult.errors.join(', ')}`); + } else { + body = bodyResult.value as Record; + } + } + + return { + request: { + method: request.method, + url: urlResult.value as string, + body, + tenantId, + contentType: request.contentType, + }, + errors, + }; + } + + /** + * Deep recursively substitute in an object + */ + private deepSubstitute( + value: unknown, + resolved: Map, + unresolvedVariables: string[] + ): unknown { + if (typeof value === 'string') { + return this.substituteString(value, resolved, unresolvedVariables); + } + + if (Array.isArray(value)) { + return value.map((item) => + this.deepSubstitute(item, resolved, unresolvedVariables) + ); + } + + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + const result: Record = {}; + + for (const [key, val] of Object.entries(obj)) { + result[key] = this.deepSubstitute(val, resolved, unresolvedVariables); + } + + return result; + } + + return value; + } + + /** + * Substitute patterns in a string + * Handles: #{var}, #{var?number}, #{UUID()}, #{ENV.VAR}, @{file}, ${file} + * + * Note: File inclusions are processed with placeholder tokens to prevent + * variable substitution within included file content. + */ + private substituteString( + str: string, + resolved: Map, + unresolvedVariables: string[] + ): string { + let result = str; + const fileInclusions: Map = new Map(); + let fileInclusionCounter = 0; + + // Step 1: Extract file inclusion patterns and replace with placeholders + // This prevents variable substitution from processing file content + + // @{file} - unescaped inclusion + result = result.replace(/@{([^}]+)}/g, (match, filePath) => { + const content = this.includeFile(filePath, false); + if (content === null) { + throw new Error(`Cannot include file: ${filePath}`); + } + const placeholder = `__FILE_INCLUDE_${fileInclusionCounter}__`; + fileInclusions.set(placeholder, content); + fileInclusionCounter++; + return placeholder; + }); + + // ${file} - JSON-escaped inclusion + result = result.replace(/\${([^}]+)}/g, (match, filePath) => { + const content = this.includeFile(filePath, true); + if (content === null) { + throw new Error(`Cannot include file: ${filePath}`); + } + const placeholder = `__FILE_INCLUDE_${fileInclusionCounter}__`; + fileInclusions.set(placeholder, content); + fileInclusionCounter++; + return placeholder; + }); + + // Step 2: Perform variable substitution (won't touch file inclusion placeholders) + // Variable patterns: #{var} or #{var?number} + result = result.replace(/#{([^}?]+)(\?[a-z]+)?}/g, (match, varName, typeHint) => { + const value = this.resolveVariable(varName, resolved); + + if (value === undefined) { + if (!unresolvedVariables.includes(varName)) { + unresolvedVariables.push(varName); + } + return match; // Return unchanged if not found + } + + // Handle type hints + if (typeHint === '?number') { + // For numeric context, just return the value as-is (no quotes) + return String(value); + } + + // For string context, convert to JSON-safe string + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }); + + // Step 3: Replace placeholders with actual file content + for (const [placeholder, content] of fileInclusions.entries()) { + // Simple replacement using split/join (works in all ES versions) + result = result.split(placeholder).join(content); + } + + return result; + } + + /** + * Resolve a single variable or special pattern + * Returns undefined if variable not found + */ + private resolveVariable( + varName: string, + resolved: Map + ): unknown { + // Special pattern: UUID() + if (varName === 'UUID()') { + return this.generateUUID(); + } + + // Special pattern: DEFAULT_TENANT_ID() + if (varName === 'DEFAULT_TENANT_ID()') { + const value = resolved.get('DEFAULT_TENANT_ID'); + if (value !== undefined) { + return value; + } + // Not found - will be handled as unresolved variable + return undefined; + } + + // Special pattern: ENV.VARNAME + if (varName.startsWith('ENV.')) { + const envVar = varName.substring(4); + return process.env[envVar]; + } + + // Check resolved map first (includes both user variables and defaults) + const value = resolved.get(varName); + if (value !== undefined) { + return value; + } + + // For FUSIONAUTH_* variables, also check environment as fallback + if (varName.startsWith('FUSIONAUTH_')) { + return process.env[varName]; + } + + // Not found + return undefined; + } + + /** + * Handle special patterns that need immediate resolution + * Used during variable initialization + */ + private resolveSpecialPattern(value: string): SubstitutionResult { + // UUID() pattern + if (value === '#{UUID()}') { + return { + success: true, + value: this.generateUUID(), + unresolvedVariables: [], + errors: [], + }; + } + + // DEFAULT_TENANT_ID() pattern + if (value === '#{DEFAULT_TENANT_ID()}') { + const tenantId = this.variables.get('DEFAULT_TENANT_ID'); + if (tenantId === undefined) { + return { + success: false, + value, + unresolvedVariables: ['DEFAULT_TENANT_ID'], + errors: ['DEFAULT_TENANT_ID not initialized. Ensure you have access to FusionAuth API.'], + }; + } + return { + success: true, + value: tenantId, + unresolvedVariables: [], + errors: [], + }; + } + + // ENV.VARNAME pattern + if (value.startsWith('#{ENV.') && value.endsWith('}')) { + const envVar = value.substring(6, value.length - 1); + const envValue = process.env[envVar]; + + if (envValue === undefined) { + return { + success: false, + value, + unresolvedVariables: [envVar], + errors: [`Environment variable not found: ${envVar}`], + }; + } + + return { + success: true, + value: envValue, + unresolvedVariables: [], + errors: [], + }; + } + + // Not a special pattern, return as-is + return { + success: true, + value, + unresolvedVariables: [], + errors: [], + }; + } + + /** + * Generate a new UUID + */ + private generateUUID(): string { + return randomUUID(); + } + + /** + * Include file content at the specified path + * @param filePath Relative path to file (relative to kickstart directory) + * @param jsonEscape Whether to JSON-escape the content + * @returns File content or null if file not found + */ + private includeFile(filePath: string, jsonEscape: boolean): string | null { + // Resolve file path relative to kickstart directory + const fullPath = path.join(this.kickstartDir, filePath); + + // Security: prevent directory traversal attacks + const resolvedPath = path.resolve(fullPath); + const kickstartDirResolved = path.resolve(this.kickstartDir); + + if (!resolvedPath.startsWith(kickstartDirResolved)) { + throw new Error( + `Invalid file path: ${filePath} (directory traversal not allowed)` + ); + } + + // Check cache first + const cacheKey = `${fullPath}:${jsonEscape}`; + if (this.fileCache.has(cacheKey)) { + return this.fileCache.get(cacheKey) || null; + } + + try { + let content = fs.readFileSync(fullPath, 'utf-8'); + + // JSON-escape if needed + if (jsonEscape) { + content = JSON.stringify(content).slice(1, -1); // Remove surrounding quotes + } + + this.fileCache.set(cacheKey, content); + return content; + } catch (err) { + return null; + } + } + + /** + * Clear file cache (useful for testing or when files change) + */ + public clearFileCache(): void { + this.fileCache.clear(); + } + + /** + * Validate that all variable references in config are resolvable + * @param config The kickstart configuration + * @param resolved Map of resolved variables + * @returns Array of unresolved variable names + */ + public findUnresolvedVariables( + config: KickstartConfig, + resolved: Map + ): string[] { + const unresolved = new Set(); + + // Check requests + for (const request of config.requests) { + this.findUnresolvedInString(request.url, resolved, unresolved); + + if (request.tenantId) { + this.findUnresolvedInString(request.tenantId, resolved, unresolved); + } + + if (request.body) { + this.findUnresolvedInObject(request.body, resolved, unresolved); + } + } + + return Array.from(unresolved); + } + + /** + * Find unresolved variables in a string + */ + private findUnresolvedInString( + str: string, + resolved: Map, + unresolved: Set + ): void { + // Skip file inclusion patterns + if (str.includes('@{') || str.includes('${')) { + return; + } + + const pattern = /#{([^}]+)}/g; + let match; + + // eslint-disable-next-line no-cond-assign + while ((match = pattern.exec(str)) !== null) { + const varName = match[1]; + + // Skip special patterns + if (varName === 'UUID()' || varName.startsWith('ENV.') || varName.startsWith('FUSIONAUTH_')) { + continue; + } + + if (!resolved.has(varName)) { + unresolved.add(varName); + } + } + } + + /** + * Find unresolved variables in an object (recursively) + */ + private findUnresolvedInObject( + obj: Record, + resolved: Map, + unresolved: Set + ): void { + const traverse = (value: unknown): void => { + if (typeof value === 'string') { + this.findUnresolvedInString(value, resolved, unresolved); + } else if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + value.forEach((item) => traverse(item)); + } else { + Object.values(value as Record).forEach((item) => + traverse(item) + ); + } + } + }; + + traverse(obj); + } + + /** + * Detect if a value is a prompt variable (matches #{PROMPT(...)}) + * @param value The variable value + * @returns true if value is a prompt variable + */ + public isPromptVariable(value: unknown): boolean { + if (typeof value !== 'string') return false; + const promptMatch = value.match(/^#\{PROMPT\('(.*)'\)\}$/); + return promptMatch !== null; + } + + /** + * Extract prompt text from a prompt variable + * @param value The variable value (e.g., "#{PROMPT('Enter API key:')}") + * @returns The prompt text without the "#{PROMPT(...)}" wrapper + */ + public extractPromptText(value: unknown): string { + if (!this.isPromptVariable(value)) { + return ''; + } + const promptMatch = (value as string).match(/^#\{PROMPT\('(.*)'\)\}$/); + return promptMatch ? promptMatch[1] : ''; + } + + /** + * Get all variables that require user input (marked with #{PROMPT(...)} pattern) + * @param variables The variables from kickstart config + * @returns Map of variable name to prompt text + */ + public getPromptedVariables(variables: Record): Map { + const prompted = new Map(); + + for (const [key, value] of Object.entries(variables)) { + if (this.isPromptVariable(value)) { + const promptText = this.extractPromptText(value); + prompted.set(key, promptText); + } + } + + return prompted; + } + + /** + * Detect if a value is a hidden prompt variable (matches #{PROMPT_HIDDEN(...)}) + * @param value The variable value + * @returns true if value is a hidden prompt variable + */ + public isHiddenPromptVariable(value: unknown): boolean { + if (typeof value !== 'string') return false; + const promptMatch = value.match(/^#\{PROMPT_HIDDEN\('(.*)'\)\}$/); + return promptMatch !== null; + } + + /** + * Extract prompt text from a hidden prompt variable + * @param value The variable value (e.g., "#{PROMPT_HIDDEN('Enter password:')}") + * @returns The prompt text without the "#{PROMPT_HIDDEN(...)}" wrapper + */ + public extractHiddenPromptText(value: unknown): string { + if (!this.isHiddenPromptVariable(value)) { + return ''; + } + const promptMatch = (value as string).match(/^#\{PROMPT_HIDDEN\('(.*)'\)\}$/); + return promptMatch ? promptMatch[1] : ''; + } + + /** + * Get all variables that require hidden user input (marked with #{PROMPT_HIDDEN(...)} pattern) + * @param variables The variables from kickstart config + * @returns Map of variable name to prompt text + */ + public getHiddenPromptedVariables(variables: Record): Map { + const prompted = new Map(); + + for (const [key, value] of Object.entries(variables)) { + if (this.isHiddenPromptVariable(value)) { + const promptText = this.extractHiddenPromptText(value); + prompted.set(key, promptText); + } + } + + return prompted; + } +} diff --git a/tsconfig.json b/tsconfig.json index 7c813c5..034f912 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,7 +52,7 @@ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ "outDir": "./dist/", /* Specify an output folder for all emitted files. */