From 48abc9fac47c8494c057efc98e4d8399fc1363e7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 9 Jul 2026 18:23:08 +0000 Subject: [PATCH 01/33] chore(release): set `package.json` to 1.9.1-next.1 [skip ci] ## [1.9.1-next.1](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.0...v1.9.1-next.1) (2026-07-09) ### Bug Fixes * **release:** adds back github token ([cd34c52](https://github.com/FusionAuth/fusionauth-node-cli/commit/cd34c52382950ebea18dc6a6e07f68fb8b5e0697)) * **release:** adds defaults ([fdac965](https://github.com/FusionAuth/fusionauth-node-cli/commit/fdac965d591b21a657fb105285b6c0898322c387)) * **release:** adds dependency of test job ([3cd41cf](https://github.com/FusionAuth/fusionauth-node-cli/commit/3cd41cf2465965658abb6d07ce5e116da402fedd)) * **release:** adds new release workflows ([9350a16](https://github.com/FusionAuth/fusionauth-node-cli/commit/9350a169050d2385c4b4f0cfd76473894ee396d0)) * **release:** adds permissions to action ([d66921b](https://github.com/FusionAuth/fusionauth-node-cli/commit/d66921ba6c2289bdeacb9388c2d1b48ec474845d)) * **release:** brings testing job into release action ([94551bd](https://github.com/FusionAuth/fusionauth-node-cli/commit/94551bdce7c7baafaae59c615b8e375329b122f5)) * **release:** fixes commitlint issues ([ac51e58](https://github.com/FusionAuth/fusionauth-node-cli/commit/ac51e588f5c879c2580f1fa0ade6c7ccfed20884)) * **release:** fixes typo in workflow ([53d1443](https://github.com/FusionAuth/fusionauth-node-cli/commit/53d1443b34cd24e177f9fd8964741519a6352de8)) * **release:** makes release config common js ([ccefcad](https://github.com/FusionAuth/fusionauth-node-cli/commit/ccefcad70568436994732baedbb21d4c4fabeb5d)) * **release:** removes extraneous test that was causing failure in build not local ([687bbd6](https://github.com/FusionAuth/fusionauth-node-cli/commit/687bbd6e766909aae65f5f50a550449c514f0c58)) * **release:** removes PR action and corrects branch for testing push ([5a1059a](https://github.com/FusionAuth/fusionauth-node-cli/commit/5a1059a0c36313b2ba5c713288735f89973faee6)) * **release:** replaces inline testing with test workflow chaining ([d5b77b7](https://github.com/FusionAuth/fusionauth-node-cli/commit/d5b77b7326925295e50ac3606194033c27e70e76)) * **release:** stop committing build output and drop unused deps ([d64ac2c](https://github.com/FusionAuth/fusionauth-node-cli/commit/d64ac2c3b37c3a260e3f9e5f31464a2b873a8bfd)), closes [#45](https://github.com/FusionAuth/fusionauth-node-cli/issues/45) * **release:** updates branches for workflow for main and next ([725d29c](https://github.com/FusionAuth/fusionauth-node-cli/commit/725d29c4216a3729c0d010cdcebf3b73aae508b9)) * **release:** updates workflow away from quotations as per the github docs ([c6dbd1a](https://github.com/FusionAuth/fusionauth-node-cli/commit/c6dbd1a53ac9ad1d22cd11256446c9893a8ea132)) --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c03f9d..363004e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [1.9.1-next.1](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.0...v1.9.1-next.1) (2026-07-09) + + +### Bug Fixes + +* **release:** adds back github token ([cd34c52](https://github.com/FusionAuth/fusionauth-node-cli/commit/cd34c52382950ebea18dc6a6e07f68fb8b5e0697)) +* **release:** adds defaults ([fdac965](https://github.com/FusionAuth/fusionauth-node-cli/commit/fdac965d591b21a657fb105285b6c0898322c387)) +* **release:** adds dependency of test job ([3cd41cf](https://github.com/FusionAuth/fusionauth-node-cli/commit/3cd41cf2465965658abb6d07ce5e116da402fedd)) +* **release:** adds new release workflows ([9350a16](https://github.com/FusionAuth/fusionauth-node-cli/commit/9350a169050d2385c4b4f0cfd76473894ee396d0)) +* **release:** adds permissions to action ([d66921b](https://github.com/FusionAuth/fusionauth-node-cli/commit/d66921ba6c2289bdeacb9388c2d1b48ec474845d)) +* **release:** brings testing job into release action ([94551bd](https://github.com/FusionAuth/fusionauth-node-cli/commit/94551bdce7c7baafaae59c615b8e375329b122f5)) +* **release:** fixes commitlint issues ([ac51e58](https://github.com/FusionAuth/fusionauth-node-cli/commit/ac51e588f5c879c2580f1fa0ade6c7ccfed20884)) +* **release:** fixes typo in workflow ([53d1443](https://github.com/FusionAuth/fusionauth-node-cli/commit/53d1443b34cd24e177f9fd8964741519a6352de8)) +* **release:** makes release config common js ([ccefcad](https://github.com/FusionAuth/fusionauth-node-cli/commit/ccefcad70568436994732baedbb21d4c4fabeb5d)) +* **release:** removes extraneous test that was causing failure in build not local ([687bbd6](https://github.com/FusionAuth/fusionauth-node-cli/commit/687bbd6e766909aae65f5f50a550449c514f0c58)) +* **release:** removes PR action and corrects branch for testing push ([5a1059a](https://github.com/FusionAuth/fusionauth-node-cli/commit/5a1059a0c36313b2ba5c713288735f89973faee6)) +* **release:** replaces inline testing with test workflow chaining ([d5b77b7](https://github.com/FusionAuth/fusionauth-node-cli/commit/d5b77b7326925295e50ac3606194033c27e70e76)) +* **release:** stop committing build output and drop unused deps ([d64ac2c](https://github.com/FusionAuth/fusionauth-node-cli/commit/d64ac2c3b37c3a260e3f9e5f31464a2b873a8bfd)), closes [#45](https://github.com/FusionAuth/fusionauth-node-cli/issues/45) +* **release:** updates branches for workflow for main and next ([725d29c](https://github.com/FusionAuth/fusionauth-node-cli/commit/725d29c4216a3729c0d010cdcebf3b73aae508b9)) +* **release:** updates workflow away from quotations as per the github docs ([c6dbd1a](https://github.com/FusionAuth/fusionauth-node-cli/commit/c6dbd1a53ac9ad1d22cd11256446c9893a8ea132)) + ## [1.9.1-next-setup.2](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.1-next-setup.1...v1.9.1-next-setup.2) (2026-07-09) From 1c0860625d0df2f920e1e6ef26a4cf4f2227752e Mon Sep 17 00:00:00 2001 From: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:24:06 -0700 Subject: [PATCH 02/33] adding workflow test on pull-request --- .github/workflows/integration-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 208a6b9..97473e2 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,6 +2,10 @@ name: Integration Tests on: workflow_dispatch: + pull_request: + branches: + - main + - mcr/add-test-to-commit jobs: integration-tests: From 3a4b5b214afd7fee71265f09ec4101c5bd1b7c72 Mon Sep 17 00:00:00 2001 From: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:34:59 -0700 Subject: [PATCH 03/33] updating with only main branch for test --- .github/workflows/integration-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 97473e2..3f98774 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,7 +5,6 @@ on: pull_request: branches: - main - - mcr/add-test-to-commit jobs: integration-tests: From 6558d43a015ffc4d947551adebc471a84648ba20 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Tue, 15 Sep 2026 16:13:00 -0400 Subject: [PATCH 04/33] Working state --- package-lock.json | 4 +- src/commands/application-update/index.ts | 1 + src/commands/application-update/update.ts | 130 ++++++++++++++ src/commands/index.ts | 1 + src/utils.ts | 197 +++++++++++++++++++++- 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/commands/application-update/index.ts create mode 100644 src/commands/application-update/update.ts diff --git a/package-lock.json b/package-lock.json index 5f6ec05..a9655a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/commands/application-update/index.ts b/src/commands/application-update/index.ts new file mode 100644 index 0000000..fb7048c --- /dev/null +++ b/src/commands/application-update/index.ts @@ -0,0 +1 @@ +export * from "./update.js"; diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts new file mode 100644 index 0000000..50c1b4e --- /dev/null +++ b/src/commands/application-update/update.ts @@ -0,0 +1,130 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, logEvent } from '../../utils.js' +import { + ApplyOptions, + ExecutionMetrics, + StepResult, + StepStatus, + ErrorCategory, +} from '../../utilities/apply/types.js'; +import { HTTPClient } from '../../utilities/apply/http-client.js'; +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { exampleApplicationBody } from "../../utils.js"; + +function getData(file: string) { + const fileLoc = path.resolve(file) + + const contentBuffer = readFileSync(fileLoc).toString('utf-8') + const contents = JSON.parse(contentBuffer) + + return contents + +} + +function setNestedProps(obj: any, path: string, value: any) { + /* Takes object and dynamically applies a property at any depth + myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} + */ + let schema = obj; + const pList = path.split('.'); + const len = pList.length; + for(var i = 0; i < len-1; i++) { + var elem = pList[i]; + if( !schema[elem] ) schema[elem] = {} + schema = schema[elem]; + } + + schema[pList[len-1]] = value; + + return schema +} + + +function displaySuccess() { + + console.log(chalk.green("Successfully submitted Application update")) + +} + + + + +export function convertOptionsToApiBody(options: any) { + let body: Record = { + application: {} + } + console.log({ options }) + if (options.redirectUrl) { + if (!body?.application?.oauthConfiguration) body.application.oauthConfiguration = {} + body.application.oauthConfiguration.authorizedRedirectURLs = [options.redirectUrl] + } + + console.log(body) + return body +} + +function splitProp(prop: string) { + const [key,value] = prop.split("=") + return {key, value} +} + +const action = async function (id: string, options: Record): Promise { + const { + host = 'http://localhost:9011', + key + } = options + const httpClient = new HTTPClient(host, key); + + if (options?.example) { + console.log(chalk.yellow("Generating example file in current directory")) + writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) + console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) + return + } + + if (options?.prop) { + let data = { application: {}} + const {key, value} = splitProp(options.prop) + data.application = await setNestedProps(data.application, `${key}`, value) + console.log(data) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + console.log(response) + return + } + + try { + if (options?.data) { + const data = await getData(options.data) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) + displaySuccess() + return + } + + } catch (e) { + console.log(e) + } + + try { + const apiBody = convertOptionsToApiBody(options) + + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + console.log(response) + return + } catch (err) { + console.log(err) + } +} +export const appUpdate = new Command() + .command('application:update') + .argument('id', "The FusionAuth Application ID to update") + .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") + .option('--redirect-url ', 'Oauth2.0 Authorized URL') + .option('-p, --prop ') + .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Sets a global config value to allow telemetry to be collected') + .action(action) diff --git a/src/commands/index.ts b/src/commands/index.ts index 07f0f21..16789d9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from './application-update/index.js' export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..d5250b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -324,4 +324,199 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} + +export const exampleApplicationBody = { + "accessControlConfiguration": {}, + "active": true, + "authenticationTokenConfiguration": { + "enabled": false + }, + "data": {}, + "emailConfiguration": {}, + "externalIdentifierConfiguration": {}, + "formConfiguration": { + "adminRegistrationFormId": "UUID", + "selfServiceFormConfiguration": { + "requireCurrentPasswordOnPasswordChange": false + } + }, + "id": "UUID", + "insertInstant": 1234, + "jwtConfiguration": { + "accessTokenKeyId": "UUID", + "enabled": true, + "idTokenKeyId": "e73fe48a-1527-43cf-9b66-9eaa4c44d909", + "refreshTokenExpirationPolicy": "Fixed", + "refreshTokenOneTimeUseConfiguration": { + "gracePeriodInSeconds": 0 + }, + "refreshTokenSlidingWindowConfiguration": { + "maximumTimeToLiveInMinutes": 43200 + }, + "refreshTokenTimeToLiveInMinutes": 43200, + "refreshTokenUsagePolicy": "Reusable", + "timeToLiveInSeconds": 3600 + }, + "lambdaConfiguration": {}, + "lastUpdateInstant": 1789396293195, + "loginConfiguration": { + "allowTokenRefresh": false, + "generateRefreshTokens": false, + "requireAuthentication": true + }, + "multiFactorConfiguration": { + "email": {}, + "sms": {}, + "voice": {} + }, + "name": "Name string", + "oauthConfiguration": { + "authorizedOriginURLs": [ + "http://localhost:3000" + ], + "authorizedRedirectURLs": [ + "http://localhost:1002" + ], + "authorizedResourceUris": [ + "http://localhost:3000" + ], + "authorizedURLValidationPolicy": "ExactMatch", + "clientAuthenticationPolicy": "NotRequiredWhenUsingPKCE", + "clientId": "UUID", + "clientSecret": "super-secret-secret-that-should-be-regenerated-for-production", + "consentMode": "AlwaysPrompt", + "debug": true, + "enabledGrants": [ + "authorization_code", + "refresh_token" + ], + "generateRefreshTokens": true, + "logoutBehavior": "AllApplications", + "logoutURL": "http://localhost:3000", + "proofKeyForCodeExchangePolicy": "Required", + "providedScopePolicy": { + "address": { + "enabled": true, + "required": false + }, + "email": { + "enabled": true, + "required": false + }, + "phone": { + "enabled": true, + "required": false + }, + "profile": { + "enabled": true, + "required": false + } + }, + "relationship": "FirstParty", + "requireClientAuthentication": true, + "requireRegistration": true, + "scopeHandlingPolicy": "Strict", + "unknownScopePolicy": "Reject" + }, + "passwordlessConfiguration": { + "emailLoginStrategy": "ClickableLink", + "enabled": false, + "phoneLoginStrategy": "FormField" + }, + "phoneConfiguration": {}, + "registrationConfiguration": { + "birthDate": { + "enabled": false, + "required": false + }, + "completeRegistration": false, + "confirmPassword": false, + "enabled": true, + "firstName": { + "enabled": false, + "required": false + }, + "fullName": { + "enabled": false, + "required": false + }, + "lastName": { + "enabled": false, + "required": false + }, + "loginIdType": "email", + "middleName": { + "enabled": false, + "required": false + }, + "mobilePhone": { + "enabled": false, + "required": false + }, + "preferredLanguages": { + "enabled": false, + "required": false + }, + "type": "basic" + }, + "registrationDeletePolicy": { + "unverified": { + "enabled": false, + "numberOfDaysToRetain": 120 + } + }, + "roles": [], + "samlv2Configuration": { + "assertionEncryptionConfiguration": { + "digestAlgorithm": "SHA256", + "enabled": false, + "encryptionAlgorithm": "AES256GCM", + "keyLocation": "Child", + "keyTransportAlgorithm": "RSA_OAEP", + "maskGenerationFunction": "MGF1_SHA1" + }, + "authorizedRedirectURLs": [], + "debug": false, + "enabled": false, + "initiatedLogin": { + "enabled": false, + "nameIdFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }, + "loginHintConfiguration": { + "enabled": true, + "parameterName": "login_hint" + }, + "logout": { + "behavior": "AllParticipants", + "requireSignedRequests": false, + "singleLogout": { + "enabled": false, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "requireSignedRequests": false, + "xmlSignatureC14nMethod": "exclusive_with_comments", + "xmlSignatureLocation": "Assertion" + }, + "scopes": [], + "state": "Active", + "tenantId": "d7d09513-a3f5-401c-9685-34ab6c552453", + "universalConfiguration": { + "universal": false + }, + "unverified": { + "behavior": "Allow" + }, + "verifyRegistration": false, + "webAuthnConfiguration": { + "bootstrapWorkflow": { + "enabled": false + }, + "enabled": false, + "reauthenticationWorkflow": { + "enabled": false + } + } +} \ No newline at end of file From 82d2f8c7a3ab625bf4e76f67cdb513b6ef964e97 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 16 Sep 2026 14:36:01 -0400 Subject: [PATCH 05/33] Feedback version --- src/commands/application-update/update.ts | 48 ++++++++++------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 50c1b4e..4a21917 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -78,24 +78,23 @@ const action = async function (id: string, options: Record): Promis } = options const httpClient = new HTTPClient(host, key); - if (options?.example) { - console.log(chalk.yellow("Generating example file in current directory")) - writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) - console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) - return - } + try { - if (options?.prop) { - let data = { application: {}} - const {key, value} = splitProp(options.prop) - data.application = await setNestedProps(data.application, `${key}`, value) - console.log(data) - const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - console.log(response) - return - } + if (options?.example) { + console.log(chalk.yellow("Generating example file in current directory")) + writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) + console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) + return + } + + if (options?.prop) { + let data = { application: {}} + const {key, value} = splitProp(options.prop) + data.application = await setNestedProps(data.application, `${key}`, value) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + return + } - try { if (options?.data) { const data = await getData(options.data) await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) @@ -103,28 +102,23 @@ const action = async function (id: string, options: Record): Promis return } + const apiBody = convertOptionsToApiBody(options) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + return + } catch (e) { console.log(e) } - try { - const apiBody = convertOptionsToApiBody(options) - - const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) - console.log(response) - return - } catch (err) { - console.log(err) - } } export const appUpdate = new Command() .command('application:update') .argument('id', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') - .option('-p, --prop ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") .addOption(hostOption) .addOption(apiKeyOption) - .description('Sets a global config value to allow telemetry to be collected') + .description('Updates an application with data provided via a file, a property, or a command flag.') .action(action) From aabc3cb0ff3d800d4cb9b2cdc3d456598e6c20fb Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 16 Sep 2026 14:42:18 -0400 Subject: [PATCH 06/33] Adds readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 884cffe..d44a0aa 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ fusionauth --help; ``` Currently, the CLI supports the following commands: +- Application Update + - `fusionauth application:update ` - Updates an application with provided data + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update + - `-p, --prop ` - Update a single property in the application + - `--redirect-url ` - Update the Authorized redirect URL for your applicatoin + - `--example` - Create an example file with editable properties to use in conjunction with the `--data` flag - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Emails From 1de5c5477f47741e3c7510ba4b916e5b4df984a9 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 17 Sep 2026 13:55:29 -0400 Subject: [PATCH 07/33] fix(update): adjust id argument into named required option --- src/commands/application-update/update.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 4a21917..9a88e1e 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -71,10 +71,11 @@ function splitProp(prop: string) { return {key, value} } -const action = async function (id: string, options: Record): Promise { +const action = async function (options: Record): Promise { const { host = 'http://localhost:9011', - key + key, + id } = options const httpClient = new HTTPClient(host, key); @@ -113,7 +114,7 @@ const action = async function (id: string, options: Record): Promis } export const appUpdate = new Command() .command('application:update') - .argument('id', "The FusionAuth Application ID to update") + .requiredOption('-i, --id ', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') From c00a2fed122c2f11f84db74f9e533178e0381918 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 17 Sep 2026 16:25:14 -0400 Subject: [PATCH 08/33] fix(update): allow for multiple --prop variables --- src/commands/application-update/update.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 9a88e1e..5b2c4ab 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -90,8 +90,8 @@ const action = async function (options: Record): Promise { if (options?.prop) { let data = { application: {}} - const {key, value} = splitProp(options.prop) - data.application = await setNestedProps(data.application, `${key}`, value) + const splitprops = options.prop.map((prop:string) => splitProp(prop)) + splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) return } @@ -117,7 +117,7 @@ export const appUpdate = new Command() .requiredOption('-i, --id ', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') - .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") .addOption(hostOption) .addOption(apiKeyOption) From 092118082f67241cf2dc9f692674e2a8d09ec753 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Fri, 18 Sep 2026 10:22:41 -0400 Subject: [PATCH 09/33] fix(update): file structure change and success messages --- src/commands/application/get.ts | 0 .../index.ts | 0 .../update.ts | 19 +++++++------------ 3 files changed, 7 insertions(+), 12 deletions(-) create mode 100644 src/commands/application/get.ts rename src/commands/{application-update => application}/index.ts (100%) rename src/commands/{application-update => application}/update.ts (90%) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/application-update/index.ts b/src/commands/application/index.ts similarity index 100% rename from src/commands/application-update/index.ts rename to src/commands/application/index.ts diff --git a/src/commands/application-update/update.ts b/src/commands/application/update.ts similarity index 90% rename from src/commands/application-update/update.ts rename to src/commands/application/update.ts index 5b2c4ab..b74e2c8 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application/update.ts @@ -1,12 +1,5 @@ import { Command } from "@commander-js/extra-typings"; import { __dirname, logEvent } from '../../utils.js' -import { - ApplyOptions, - ExecutionMetrics, - StepResult, - StepStatus, - ErrorCategory, -} from '../../utilities/apply/types.js'; import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; @@ -43,10 +36,8 @@ function setNestedProps(obj: any, path: string, value: any) { } -function displaySuccess() { - - console.log(chalk.green("Successfully submitted Application update")) - +function displaySuccess(message:string = "Successfully submitted Application update") { + console.log(chalk.green(message)) } @@ -80,6 +71,7 @@ const action = async function (options: Record): Promise { const httpClient = new HTTPClient(host, key); try { + logEvent('cli application:create') if (options?.example) { console.log(chalk.yellow("Generating example file in current directory")) @@ -93,18 +85,21 @@ const action = async function (options: Record): Promise { const splitprops = options.prop.map((prop:string) => splitProp(prop)) splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + displaySuccess(`Applied patch\n${JSON.stringify(data,null,2)}`) + return } if (options?.data) { const data = await getData(options.data) await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) - displaySuccess() + displaySuccess(`Applied patch\n${JSON.stringify(data,null,2)}`) return } const apiBody = convertOptionsToApiBody(options) await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + displaySuccess(`Applied patch\n${JSON.stringify(apiBody,null,2)}`) return } catch (e) { From a646aa898ecf2710a6d3d39ebeca33b16319da66 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:32:57 -0600 Subject: [PATCH 10/33] Andrewpai/yes flag (#55) * adding workflow test on pull-request * updating with only main branch for test * Trying out --yes on kickstart:kill * removed promotional logging for dotenvx * camel-> kebab case, tests * package-lock version update * Address PR review feedback - import-generate: detect deprecated flags in --flag=value form, not just bare --flag - kickstart-install: replace setTimeout-chained install steps with sequential awaited steps so errors propagate through try/catch and ordering is deterministic; also await createKickstart (was previously fire-and-forget) - utils: confirmOrExit now requires both stdin and stdout to be TTYs before treating the session as interactive, and normalizes confirmation input (trims whitespace, accepts y/yes case-insensitively) * Add test coverage for confirmOrExit and kickstart:kill - utils.ts: extract isConfirmationAccepted() as a pure, exported function so the accept/reject decision logic can be unit tested directly without simulating a real TTY - kickstart-kill.ts: export action() and add an injectable deps parameter (isDockerInstalled, confirmOrExit, spawn) so tests can exercise the confirmation gating without touching real docker or exiting the process - add __tests__/utils.test.js covering isConfirmationAccepted and the yes-bypass / non-interactive TTY-detection paths of confirmOrExit - add __tests__/commands/kickstart-kill.test.js covering docker-not-installed, CLI_DIR mismatch, --yes bypass, and confirm-rejected gating paths - wire both new test files into the test and test:unit npm scripts * Fix confirmOrExit silently proceeding when process.exit is mocked/deferred Previously, the rl.question callback called process.exit(0) on decline but had no return statement, so resolve() ran unconditionally afterward. In production this was masked because process.exit halts execution synchronously, but in any environment where exit is mocked or deferred (e.g. tests), a declined confirmation would be silently treated as accepted, letting the caller proceed with the risky operation. - extract handleConfirmationAnswer(answer, resolve, reject): resolves on accept, exits + rejects on decline, so the promise can never silently resolve when exit doesn't actually happen - confirmOrExit now passes both resolve and reject into handleConfirmationAnswer - add 3 tests in __tests__/utils.test.js covering accept, decline, and the decline-with-mocked-exit case that reproduces the original bug * Add test coverage for import:generate deprecated-flag detection - extract getDeprecatedFlagUsage(argv) as a pure, exported function so the deprecation-detection logic is testable without mocking process.argv or console.warn - export DEPRECATED_FLAGS for use in tests - add __tests__/commands/import-generate.test.js covering: no deprecated flags used, bare --flag and --flag=value forms detected, multiple deprecated flags detected together, new kebab-case form not flagged, and that both the deprecated and current flag spellings populate the same underlying Commander option property - wire the new test file into the test and test:unit npm scripts * Fix crypto.randomUUID global usage and confirmOrExit non-interactive gap - kickstart-install.ts: import randomUUID from node:crypto explicitly instead of relying on the global WebCrypto object, matching the convention already used elsewhere in the codebase - utils.ts: confirmOrExit() now throws after errorAndExit() in the non-interactive path, mirroring the fix already applied to handleConfirmationAnswer in the interactive path. In production this is a no-op since process.exit(1) halts synchronously first, but in any environment where exit is mocked/deferred, the promise now rejects instead of silently resolving and letting the caller proceed with the risky operation - update the three non-interactive tests in __tests__/utils.test.js to assert.rejects, which now actually exercises the fixed behavior * fix(ci): remove stale duplicate pull_request trigger from test workflow The rebase onto next carried forward an old commit that added a second pull_request trigger (scoped to branches: main) to what was then integration-tests.yml. That file has since been renamed to test.yaml on next, which already has its own unscoped pull_request trigger. The duplicate key is invalid YAML (most parsers, including GitHub Actions', silently keep only the last occurrence), which risked the main-scoped trigger silently overriding the intended unscoped one and breaking CI for PRs targeting next. Removed the stale block. next takes priority over main going forward, so a main-specific trigger no longer serves any purpose here. File is now byte-identical to next's original. * fix(test): pin integration test FusionAuth image to 1.69.2 The integration test fixture pinned fusionauth/fusionauth-app:latest, a floating tag. This made the integration test's pass/fail status depend on whatever FusionAuth happened to publish as latest at run time, independent of anything in this repo's history. Pin to 1.69.2 (current release) for reproducible test runs. Confirmed passing against a clean container/volume state. The kickstart:install command's own docker-compose.yml template (src/resources/kickstart/fusionauth/docker-compose.yml), which gets copied into end users' projects, intentionally remains on :latest so new installs always get the current FusionAuth release. * test: cleaned up tests and brought in validator lib for email validation * chore: remove CONTRIBUTING.md Content will be migrated into README.md separately. * fix: exit with non-zero status on kickstart:install failure, fix typo The outer catch block only logged the error and let the command return successfully. A failed file copy, kickstart-file write, rename, or environment update would produce an error message while the CLI still exited with status 0, masking failures from scripts/CI that check the exit code. Set process.exitCode = 1 in that path. Also fix a JSDoc typo: intial -> initial. * test: replaced mock-fs with real temp dir-based file testing --------- Co-authored-by: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> --- AGENTS.md | 6 + __tests__/commands/import-generate.test.js | 64 ++++ __tests__/commands/kickstart-install.test.js | 329 ++++++++++++++++++ __tests__/commands/kickstart-kill.test.js | 128 +++++++ __tests__/helpers/temp-dir.js | 26 ++ .../docker-compose.yml | 2 +- __tests__/postInstall/postinstall.test.js | 129 +++---- __tests__/telemetry/index.js | 148 -------- __tests__/telemetry/telemetry.test.js | 149 ++++---- .../utilities/kickstart/validator.test.js | 123 ++++--- .../kickstart/variable-substitution.test.js | 1 - __tests__/utils.test.js | 139 ++++++++ package-lock.json | 40 +-- package.json | 4 +- src/commands/import-generate.ts | 48 ++- src/commands/kickstart-install.ts | 251 +++++++++---- src/commands/kickstart-kill.ts | 73 ++-- src/commands/telemetry/telemetry-utils.ts | 6 +- src/utils.ts | 82 ++++- 19 files changed, 1252 insertions(+), 496 deletions(-) create mode 100644 __tests__/commands/import-generate.test.js create mode 100644 __tests__/commands/kickstart-install.test.js create mode 100644 __tests__/commands/kickstart-kill.test.js create mode 100644 __tests__/helpers/temp-dir.js delete mode 100644 __tests__/telemetry/index.js create mode 100644 __tests__/utils.test.js diff --git a/AGENTS.md b/AGENTS.md index 1e9e6f5..8649667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,12 @@ - Custom error reporting via `utils.reportError()` and `utils.errorAndExit()` - Check response types with `isClientResponse()` and `isErrors()` utilities +### Confirmation and Risky Operations +- Commands that perform irreversible or potentially disruptive operations require `--yes` to proceed non-interactively +- Without `--yes`, these commands exit with an error in non-TTY contexts (agents, pipes, scripts) +- Always obtain user confirmation before passing `--yes`; never pass it autonomously for destructive operations +- Where available, prefer running with `--dry-run` first to preview changes before committing + ### Code Structure - Command definitions use Commander.js with fluent API - JSDoc comments for function documentation diff --git a/__tests__/commands/import-generate.test.js b/__tests__/commands/import-generate.test.js new file mode 100644 index 0000000..c64fc1f --- /dev/null +++ b/__tests__/commands/import-generate.test.js @@ -0,0 +1,64 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { getDeprecatedFlagUsage, importGenerate } from "../../src/commands/import-generate.js" + +describe('getDeprecatedFlagUsage()', () => { + test('returns empty array when no deprecated flags are used', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--number-of-files', '5']) + assert.deepEqual(usage, []) + }) + + test('detects a bare deprecated flag (--flag value form)', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects a deprecated flag in --flag=value form', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles=5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects multiple deprecated flags used together', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5', '--groupId=abc']) + const oldFlags = usage.map(([old]) => old) + assert.ok(oldFlags.includes('--numberOfFiles')) + assert.ok(oldFlags.includes('--groupId')) + assert.equal(usage.length, 2) + }) + + test('does not flag the new kebab-case form as deprecated', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--group-id', 'abc']) + assert.deepEqual(usage, []) + }) +}) + +describe('import:generate option parsing', () => { + test('deprecated --numberOfFiles populates the same option as --number-of-files', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--numberOfFiles', '5'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '5') + }) + + test('--number-of-files populates the same numberOfFiles property', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--number-of-files', '7'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '7') + }) + + test('deprecated --groupId populates the same option as --group-id', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--groupId', 'abc-123'], { from: 'user' }) + + assert.equal(capturedOptions.groupId, 'abc-123') + }) +}) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js new file mode 100644 index 0000000..cf7ba13 --- /dev/null +++ b/__tests__/commands/kickstart-install.test.js @@ -0,0 +1,329 @@ +import { describe, test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + validateEmail, + validatePassword, + resolveInstallAnswers, +} from '../../src/commands/kickstart-install.js' + +// --------------------------------------------------------------------------- +// validateEmail +// --------------------------------------------------------------------------- + +describe('validateEmail()', () => { + test('accepts a standard email address', () => { + assert.equal(validateEmail('admin@example.com'), true) + }) + + test('rejects an address with no @', () => { + const result = validateEmail('notanemail') + assert.notEqual(result, true) + assert.match(result, /valid email/) + }) + + test('rejects an empty string', () => { + const result = validateEmail('') + assert.notEqual(result, true) + }) +}) + +// --------------------------------------------------------------------------- +// validatePassword +// --------------------------------------------------------------------------- + +describe('validatePassword()', () => { + test('accepts a password of exactly 8 characters', () => { + assert.equal(validatePassword('abcdefgh'), true) + }) + + test('rejects an empty password', () => { + const result = validatePassword('') + assert.notEqual(result, true) + assert.match(result, /required/) + }) + + test('rejects a password one character short of the minimum', () => { + const result = validatePassword('1234567') + assert.notEqual(result, true) + assert.match(result, /8 characters/) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI options only (no prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — all options provided', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.TEST_ADMIN_PASS + process.env.TEST_ADMIN_PASS = 'supersecret' + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.TEST_ADMIN_PASS + } else { + process.env.TEST_ADMIN_PASS = savedEnv + } + }) + + test('returns answers from CLI options without calling promptFn', async () => { + const neverCallMe = () => { + throw new Error('promptFn should not have been called') + } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'TEST_ADMIN_PASS', + applicationName: 'My App', + }, + neverCallMe + ) + + assert.equal(answers.email, 'agent@example.com') + assert.equal(answers.password, 'supersecret') + assert.equal(answers.appName, 'My App') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — no CLI options (all prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — no options provided', () => { + test('calls promptFn with questions for all three fields', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers({}, mockPrompt) + + assert.equal(answers.email, 'prompted@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — partial CLI options +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — only adminEmail provided', () => { + test('does not include email in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers( + { adminEmail: 'cli@example.com' }, + mockPrompt + ) + + assert.equal(answers.email, 'cli@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('email'), 'should not ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +describe('resolveInstallAnswers() — only applicationName provided', () => { + test('does not include appName in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass' } + } + + const answers = await resolveInstallAnswers( + { applicationName: 'CLI App' }, + mockPrompt + ) + + assert.equal(answers.appName, 'CLI App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('appName'), 'should not ask for appName') + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — adminPasswordEnv resolution +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — admin-password-env', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.MY_ADMIN_PASS + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.MY_ADMIN_PASS + } else { + process.env.MY_ADMIN_PASS = savedEnv + } + }) + + test('reads the password from the named environment variable', async () => { + process.env.MY_ADMIN_PASS = 'envpassword' + + const neverCallMe = () => { throw new Error('promptFn should not have been called') } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'Test App', + }, + neverCallMe + ) + + assert.equal(answers.password, 'envpassword') + }) + + test('throws when the named environment variable is not set', async () => { + delete process.env.MY_ADMIN_PASS + + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'agent@example.com', adminPasswordEnv: 'MY_ADMIN_PASS', applicationName: 'App' }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /MY_ADMIN_PASS/) + assert.match(err.message, /not set/) + return true + } + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI validation errors +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — CLI validation errors', () => { + test('throws on invalid --admin-email', async () => { + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'not-an-email', adminPasswordEnv: undefined, applicationName: undefined }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-email/) + assert.match(err.message, /valid email/) + return true + } + ) + }) + + test('throws when env var password is too short', async () => { + process.env.MY_ADMIN_PASS = 'short' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /8 characters/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) + + test('throws when env var password is empty', async () => { + process.env.MY_ADMIN_PASS = '' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /required/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — inquirer validate functions are wired correctly +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — inquirer validate functions', () => { + test('email question uses validateEmail directly', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const emailQuestion = capturedQuestions.find((q) => q.name === 'email') + assert.ok(emailQuestion, 'email question should exist') + assert.equal(emailQuestion.validate, validateEmail) + }) + + test('password question uses validatePassword directly', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const passwordQuestion = capturedQuestions.find((q) => q.name === 'password') + assert.ok(passwordQuestion, 'password question should exist') + assert.equal(passwordQuestion.validate, validatePassword) + }) +}) diff --git a/__tests__/commands/kickstart-kill.test.js b/__tests__/commands/kickstart-kill.test.js new file mode 100644 index 0000000..37bcf08 --- /dev/null +++ b/__tests__/commands/kickstart-kill.test.js @@ -0,0 +1,128 @@ +import { describe, test, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import { action } from "../../src/commands/kickstart-kill.js" + +/** + * Fake child-process-like object returned by mocked spawn — supports the + * minimal surface kickstart-kill's action() touches (.on, .stdout) without + * running any real process. + */ +function fakeChildProcess() { + return { + on: () => {}, + stdout: undefined, + } +} + +describe('kickstart:kill action()', () => { + // action() calls logEvent() internally (unmocked). Disable telemetry so it + // short-circuits before touching the real filesystem or network. + beforeEach(() => { + process.env.FUSIONAUTH_TELEMETRY = 'false' + }) + + afterEach(() => { + delete process.env.FUSIONAUTH_TELEMETRY + }) + + test('does not call confirmOrExit or spawn when Docker is not installed', async () => { + const confirmCalls = [] + const spawnCalls = [] + + await action( + { yes: false }, + { + isDockerInstalled: () => false, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('does not call confirmOrExit or spawn when CLI_DIR does not match cwd', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = '/not/the/current/directory' + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('yes=true calls confirmOrExit (which resolves immediately) then spawn', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: true }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 1, 'confirmOrExit should be called once') + assert.equal(confirmCalls[0][1], true, 'confirmOrExit should receive yes=true') + assert.equal(spawnCalls.length, 1, 'spawn should be called once') + assert.equal(spawnCalls[0][0], 'docker compose down -v') + }) + + test('when confirmOrExit rejects (declined/non-interactive), spawn is never called', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async () => { throw new Error('declined') }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) +}) diff --git a/__tests__/helpers/temp-dir.js b/__tests__/helpers/temp-dir.js new file mode 100644 index 0000000..6eb841c --- /dev/null +++ b/__tests__/helpers/temp-dir.js @@ -0,0 +1,26 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +/** + * Creates a real, unique temporary directory outside the repo (under the OS + * temp dir) for tests that need to exercise real filesystem behavior instead + * of mocking it. Prefer this over mock-fs, which has had shaky support for + * newer Node versions. + * + * @param prefix Prefix for the generated directory name. + * @returns The absolute path to the newly created temp directory. + */ +export function createTempDir(prefix = 'fa-cli-') { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) +} + +/** + * Recursively removes a temp directory created by createTempDir(). Safe to + * call even if the directory doesn't exist. + * + * @param dir The directory to remove. + */ +export function removeTempDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }) +} 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 index 6ac70fb..2314490 100644 --- a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml @@ -46,7 +46,7 @@ services: - search_net fusionauth: - image: fusionauth/fusionauth-app:latest + image: fusionauth/fusionauth-app:1.69.2 depends_on: db: condition: service_healthy diff --git a/__tests__/postInstall/postinstall.test.js b/__tests__/postInstall/postinstall.test.js index cfcd096..3315af0 100644 --- a/__tests__/postInstall/postinstall.test.js +++ b/__tests__/postInstall/postinstall.test.js @@ -1,96 +1,73 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" +import path from "node:path" import { createConfig } from '../../src/utils.js' +import { createTempDir, removeTempDir } from '../helpers/temp-dir.js' -import mock from 'mock-fs' -import fs, { readdirSync, readFileSync } from 'node:fs' +import fs from 'node:fs' describe('postInstall runs properly', () => { + let tempDir + let configDir + + beforeEach(() => { + tempDir = createTempDir() + configDir = path.join(tempDir, 'dist', '.fa') + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('No config creates dir', () => { - mock({ - 'dist': {}, - }) - try { - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') - } finally { - mock.restore() - } + const configFileExists = createConfig(configDir) + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') }) + test('No dist directory, still create the directory and file', () => { - mock({ - "./": {} - }) - try { - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') - } finally { - mock.restore() - } + // tempDir exists but the nested dist/.fa path does not yet. + const configFileExists = createConfig(configDir) + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') }) + test('No config creates full config file with expected types', () => { - 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() - } + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, '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") }) test('Complete config returns false', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true}) - } - } - }) - try { - assert.equal(createConfig('dist/.fa'), false, 'Postinstall did not return false properly') - } finally { - mock.restore() - } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true }) + ) + + assert.equal(createConfig(configDir), false, 'Postinstall did not return false properly') }) + test('No ID in config, but telemetry false', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({telemetry: false}) - } - } - }) - 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', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb'}) - } - } - }) - 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() - } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ telemetry: false })) + + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'))) + assert.equal(typeof configObject.id, 'string', 'No ID after run') + assert.equal(configObject.telemetry, false, 'Telemetry got reset') }) + test('No telemetry in config, but ID', () => { + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb' }) + ) + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'))) + assert.equal(configObject.id, '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', 'ID got reset') + assert.equal(configObject.telemetry, true, 'Telemetry did not get set') + }) }) diff --git a/__tests__/telemetry/index.js b/__tests__/telemetry/index.js deleted file mode 100644 index a56d1f2..0000000 --- a/__tests__/telemetry/index.js +++ /dev/null @@ -1,148 +0,0 @@ -import test, { describe, after, before, beforeEach, afterEach } from "node:test" -import assert from "node:assert" -import fs, { readFileSync } from "node:fs" -import mock from "mock-fs" -import { telemetryUpdate } from "../../dist/commands/telemetry/telemetry-utils.js" -import { telemetryDisable } from "../../dist/commands/telemetry/telemetry-disable.js" -import { telemetryEnable } from "../../dist/commands/telemetry/telemetry-enable.js" -import path from "node:path" -import { loadConfig, logEvent } from "../../dist/utils.js" -import nock from 'nock' - -export function telemetry() { - const mockedTrueConfig = { - id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - telemetry: true, - version: '1.0' - } - const mockedFalseConfig = { - id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - 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") - }) - test("Only changes telemetry value", () => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - }) - - const updatedConfig = telemetryUpdate(true) - assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) - }) - - test("Enable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - }) - test("Disable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - }) - test("Disable full command runs properly", (t) => { - before(() => { - 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) - }) - test("Enable full command runs properly", (t) => { - before(() => { - 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) - }) - }) - describe('tests for logEvent', () => { - test("If FUSIONAUTH_TELEMETRY === false don't run", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = false - }) - after(() => { - delete process.env.FUSIONAUTH_TELEMETRY - }) - - const response = await logEvent('test event') - assert.equal(response, false, "logEvent still fired") - }) - - test("If FUSIONAUTH_TELEMETRY === true DO run", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = true - nock('https://us.i.posthog.com') - .post('/batch/') - .reply(200) - }) - after(() => { - nock.cleanAll(); - delete process.env.FUSIONAUTH_TELEMETRY - }) - - const response = await logEvent('test event') - assert.equal(response, true, "logEvent didn't fire") - }) - test("If no .env, event submits", async (t) => { - before(() => { - nock('https://us.i.posthog.com') - .post('/batch/') - .reply(200) - }) - after(() => { - nock.cleanAll(); - }) - - assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') - const response = await logEvent('test event') - assert.equal(response, true, "logEvent didn't fire") - }) - - test("Disables warning after first log", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = true - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - }) - after(() => { - delete process.env.FUSIONAUTH_TELEMETRY - mock.restore() - }) - await logEvent('cli test') - const newConfig = await loadConfig() - assert.equal(newConfig.globalConfig.telemetryNoWarn, true) - }) - - }) -} diff --git a/__tests__/telemetry/telemetry.test.js b/__tests__/telemetry/telemetry.test.js index d562f94..8de09df 100644 --- a/__tests__/telemetry/telemetry.test.js +++ b/__tests__/telemetry/telemetry.test.js @@ -1,13 +1,13 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" -import fs, { readFileSync } from "node:fs" -import mock from "mock-fs" +import fs from "node:fs" +import path from "node:path" import { telemetryUpdate } from "../../src/commands/telemetry/telemetry-utils.js" import { telemetryDisable } from "../../src/commands/telemetry/telemetry-disable.js" import { telemetryEnable } from "../../src/commands/telemetry/telemetry-enable.js" -import path from "node:path" -import { logEvent } from "../../src/utils.js" +import { logEvent, loadConfig } from "../../src/utils.js" import nock from 'nock' +import { createTempDir, removeTempDir } from '../helpers/temp-dir.js' const mockedTrueConfig = { id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', @@ -20,97 +20,126 @@ const mockedFalseConfig = { version: '1.0' } +// All telemetry helpers read/write a global config file at +// `${FUSIONAUTH_CONFIG_DIR}/.fa/config.json`. Point that at a fresh real +// temp directory per test rather than mocking the filesystem, and rather +// than letting these tests write to the real repo's src/.fa/ directory. describe('telemetry runs properly', () => { + let tempDir + let configPath + + beforeEach(() => { + tempDir = createTempDir() + process.env.FUSIONAUTH_CONFIG_DIR = tempDir + configPath = path.join(tempDir, '.fa', 'config.json') + }) + + afterEach(() => { + delete process.env.FUSIONAUTH_CONFIG_DIR + removeTempDir(tempDir) + }) + + function writeConfig(config) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, JSON.stringify(config)) + } + test("Creates config if no config exists", () => { - mock({ - "src": {} - }) - try { - const updatedConfig = telemetryUpdate(true) - assert(fs.existsSync('src/.fa/config.json'), "File wasn't created") - } finally { - mock.restore() - } + const updatedConfig = telemetryUpdate(true) + assert(fs.existsSync(configPath), "File wasn't created") }) test("Only changes telemetry value", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - try { - const updatedConfig = telemetryUpdate(true) - assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) - } finally { - mock.restore() - } + writeConfig(mockedFalseConfig) + const updatedConfig = telemetryUpdate(true) + assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) }) test("Enable works", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - try { - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - } finally { - mock.restore() - } + writeConfig(mockedFalseConfig) + const actualConfig = telemetryUpdate(true) + assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") }) test("Disable works", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - try { - const actualConfig = telemetryUpdate(false) - assert.equal(actualConfig.globalConfig.telemetry, false, "Telemetry not set to false") - } finally { - mock.restore() - } + writeConfig(mockedTrueConfig) + const actualConfig = telemetryUpdate(false) + assert.equal(actualConfig.globalConfig.telemetry, false, "Telemetry not set to false") }) test("Disable full command runs properly", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) + writeConfig(mockedTrueConfig) try { telemetryDisable.parse() - const actualConfig = JSON.parse(fs.readFileSync('src/.fa/config.json').toString()) + const actualConfig = JSON.parse(fs.readFileSync(configPath).toString()) assert.equal(actualConfig.telemetry, false) } finally { - mock.restore() + nock.cleanAll() } }) test("Enable full command runs properly", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) + writeConfig(mockedFalseConfig) try { telemetryEnable.parse() - const actualConfig = JSON.parse(fs.readFileSync('src/.fa/config.json').toString()) + const actualConfig = JSON.parse(fs.readFileSync(configPath).toString()) assert.equal(actualConfig.telemetry, true) } finally { - mock.restore() + nock.cleanAll() } }) - }) - describe('tests for logEvent', () => { +}) + +describe('tests for logEvent', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + process.env.FUSIONAUTH_CONFIG_DIR = tempDir + }) + + afterEach(() => { + delete process.env.FUSIONAUTH_CONFIG_DIR + delete process.env.FUSIONAUTH_TELEMETRY + removeTempDir(tempDir) + }) + test("If FUSIONAUTH_TELEMETRY === false don't run", async () => { process.env.FUSIONAUTH_TELEMETRY = 'false' + const response = await logEvent('test event') + assert.equal(response, false, "logEvent still fired") + }) + test("If no .env, event submits", async () => { + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) try { + assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') const response = await logEvent('test event') - assert.equal(response, false, "logEvent still fired") + assert.equal(response, true, "logEvent didn't fire") } finally { - delete process.env.FUSIONAUTH_TELEMETRY + nock.cleanAll() } }) - test("If no .env, event submits", async () => { + // Ported forward from the now-removed __tests__/telemetry/index.js — this + // is the one test there that covered behavior not exercised anywhere + // else: that the "we collect anonymous data" warning only prints once. + test("Disables warning after first log", async () => { nock('https://us.i.posthog.com') + .persist() .post('/batch/') .reply(200) try { - assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') - const response = await logEvent('test event') + const response = await logEvent('cli test') assert.equal(response, true, "logEvent didn't fire") + const newConfig = loadConfig() + assert.equal(newConfig.globalConfig.telemetryNoWarn, true) } finally { nock.cleanAll() } }) - - }) +}) diff --git a/__tests__/utilities/kickstart/validator.test.js b/__tests__/utilities/kickstart/validator.test.js index 2e9196a..b71b0cf 100644 --- a/__tests__/utilities/kickstart/validator.test.js +++ b/__tests__/utilities/kickstart/validator.test.js @@ -1,7 +1,9 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" -import mock from "mock-fs" +import fs from "node:fs" +import path from "node:path" import { KickstartValidator } from "../../../src/utilities/kickstart/validator.js" +import { createTempDir, removeTempDir } from "../../helpers/temp-dir.js" describe('KickstartValidator', () => { @@ -304,6 +306,16 @@ describe('KickstartValidator', () => { }) describe('validateFileExists()', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('should report error for missing file', () => { const validator = new KickstartValidator() const result = validator.validateFileExists('/nonexistent/file.json') @@ -313,37 +325,36 @@ describe('KickstartValidator', () => { }) test('should accept existing file', () => { - 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() - } + const filePath = path.join(tempDir, 'kickstart.json') + fs.writeFileSync(filePath, '{"requests": []}') + + const validator = new KickstartValidator() + const result = validator.validateFileExists(filePath) + + assert.equal(result.valid, true) + assert.equal(result.errors.length, 0) }) test('should report error if path is directory', () => { - 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() - } + const validator = new KickstartValidator() + const result = validator.validateFileExists(tempDir) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('not a file'))) }) }) describe('loadAndValidateJSON()', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('should return error if file not found', () => { const validator = new KickstartValidator() const result = validator.loadAndValidateJSON('/nonexistent.json') @@ -353,18 +364,14 @@ describe('KickstartValidator', () => { }) test('should return error if JSON is invalid', () => { - 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() - } + const filePath = path.join(tempDir, 'bad.json') + fs.writeFileSync(filePath, '{ invalid json }') + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.category === 'schema_invalid')) }) test('should load and parse valid JSON', () => { @@ -373,19 +380,15 @@ describe('KickstartValidator', () => { { 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() - } + const filePath = path.join(tempDir, 'valid.json') + fs.writeFileSync(filePath, JSON.stringify(config)) + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert('config' in result) + assert.equal(result.config.requests.length, 1) + assert('lineNumbers' in result) }) test('should include line numbers in result', () => { @@ -395,17 +398,13 @@ describe('KickstartValidator', () => { { 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() - } + const filePath = path.join(tempDir, 'valid.json') + fs.writeFileSync(filePath, JSON.stringify(config)) + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert('lineNumbers' in result) }) }) }) diff --git a/__tests__/utilities/kickstart/variable-substitution.test.js b/__tests__/utilities/kickstart/variable-substitution.test.js index 62826d3..c593664 100644 --- a/__tests__/utilities/kickstart/variable-substitution.test.js +++ b/__tests__/utilities/kickstart/variable-substitution.test.js @@ -1,7 +1,6 @@ import { describe, test, afterEach } from "node:test" import assert from "node:assert/strict" import nock from "nock" -import mock from 'mock-fs' import { VariableSubstitutor } from "../../../src/utilities/kickstart/variable-substitution.js" describe('VariableSubstitutor', () => { diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js new file mode 100644 index 0000000..2f2b097 --- /dev/null +++ b/__tests__/utils.test.js @@ -0,0 +1,139 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { isConfirmationAccepted, handleConfirmationAnswer, confirmOrExit } from "../src/utils.js" + +describe('isConfirmationAccepted()', () => { + test('accepts "y"', () => { + assert.equal(isConfirmationAccepted('y'), true) + }) + + test('accepts "yes"', () => { + assert.equal(isConfirmationAccepted('yes'), true) + }) + + test('accepts case-insensitive variants', () => { + assert.equal(isConfirmationAccepted('Y'), true) + assert.equal(isConfirmationAccepted('YES'), true) + assert.equal(isConfirmationAccepted('Yes'), true) + }) + + test('accepts whitespace-padded variants', () => { + assert.equal(isConfirmationAccepted(' y '), true) + assert.equal(isConfirmationAccepted(' yes '), true) + }) + + test('rejects "n"', () => { + assert.equal(isConfirmationAccepted('n'), false) + }) + + test('rejects empty string', () => { + assert.equal(isConfirmationAccepted(''), false) + }) + + test('rejects unrelated text', () => { + assert.equal(isConfirmationAccepted('nope'), false) + assert.equal(isConfirmationAccepted('ye'), false) + assert.equal(isConfirmationAccepted('sure'), false) + }) +}) + +describe('handleConfirmationAnswer()', () => { + test('accepted answer calls resolve, not reject or process.exit', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejected = false + + handleConfirmationAnswer('y', () => { resolved = true }, () => { rejected = true }) + + assert.equal(resolved, true) + assert.equal(rejected, false) + assert.equal(exitMock.mock.calls.length, 0) + }) + + test('declined answer calls process.exit(0)', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + handleConfirmationAnswer('n', () => {}, () => {}) + + assert.equal(exitMock.mock.calls.length, 1) + assert.equal(exitMock.mock.calls[0].arguments[0], 0) + }) + + test('declined answer rejects rather than resolving when process.exit is mocked (does not actually exit)', (t) => { + t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejectedWith + + handleConfirmationAnswer('n', () => { resolved = true }, (err) => { rejectedWith = err }) + + assert.equal(resolved, false, 'resolve should never be called on decline') + assert.ok(rejectedWith instanceof Error, 'reject should be called with an Error') + }) +}) + +describe('confirmOrExit()', () => { + test('yes=true resolves immediately without touching stdin/stdout', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + await confirmOrExit('This is risky', true) + + assert.equal(exitMock.mock.calls.length, 0, 'process.exit should not be called') + }) + + test('non-interactive (stdin not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = true + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (stdout not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = true + process.stdout.isTTY = false + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (both not TTYs) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = false + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) +}) diff --git a/package-lock.json b/package-lock.json index 252d350..fd8ea8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "queue": "7.0.0", "remove-undefined-objects": "9.0.0", "uuid": "14.0.1", + "validator": "^13.15.35", "yocto-spinner": "^1.2.1" }, "bin": { @@ -41,10 +42,9 @@ "@types/figlet": "1.7.0", "@types/fs-extra": "11.0.4", "@types/html-to-text": "9.0.4", - "@types/mock-fs": "^4.13.4", "@types/node": "26.1.1", + "@types/validator": "^13.15.10", "husky": "^9.1.7", - "mock-fs": "^5.5.0", "nock": "^14.0.16", "tsx": "^4.23.0", "type-fest": "5.8.0", @@ -1309,16 +1309,6 @@ "@types/node": "*" } }, - "node_modules/@types/mock-fs": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", - "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1329,6 +1319,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2758,16 +2755,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mock-fs": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", - "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/mute-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", @@ -3224,6 +3211,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index bd605d5..77ba0b0 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "queue": "7.0.0", "remove-undefined-objects": "9.0.0", "uuid": "14.0.1", + "validator": "^13.15.35", "yocto-spinner": "^1.2.1" }, "devDependencies": { @@ -64,10 +65,9 @@ "@types/figlet": "1.7.0", "@types/fs-extra": "11.0.4", "@types/html-to-text": "9.0.4", - "@types/mock-fs": "^4.13.4", "@types/node": "26.1.1", + "@types/validator": "^13.15.10", "husky": "^9.1.7", - "mock-fs": "^5.5.0", "nock": "^14.0.16", "tsx": "^4.23.0", "type-fest": "5.8.0", diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 9ebecb7..75de421 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -1,4 +1,4 @@ -import {Command} from '@commander-js/extra-typings'; +import {Command, Option} from '@commander-js/extra-typings'; import {FusionAuthClient} from '@fusionauth/typescript-client'; import {readFile} from 'fs/promises'; import chalk from 'chalk'; @@ -7,6 +7,31 @@ import {errorAndExit, logEvent} from '../utils.js'; import { faker } from '@faker-js/faker'; import * as fs from 'fs'; +export const DEPRECATED_FLAGS: Record = { + '--numberOfFiles': '--number-of-files', + '--countPerFile': '--count-per-file', + '--applicationId': '--application-id', + '--groupId': '--group-id', + '--tmpDir': '--tmp-dir', + '--filePrefix': '--file-prefix', +}; + +// Exported for testability — pure function, no I/O; returns [oldFlag, replacement] pairs found in argv. +export function getDeprecatedFlagUsage(argv: string[]): Array<[string, string]> { + return Object.entries(DEPRECATED_FLAGS).filter(([old]) => + argv.some((arg) => arg === old || arg.startsWith(`${old}=`)) + ); +} + +function warnDeprecatedFlags(argv: string[] = process.argv): void { + for (const [old, replacement] of getDeprecatedFlagUsage(argv)) { + console.warn(chalk.yellow( + `DEPRECATION WARNING: please use ${replacement} going forward. ` + + `${old} will be deprecated in a future release.` + )); + } +} + const action = async function ({numberOfFiles, countPerFile, applicationId, groupId, tmpDir, filePrefix} : { numberOfFiles?: string | undefined; @@ -17,6 +42,8 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou filePrefix?: string | undefined; } ): Promise { + warnDeprecatedFlags(); + logEvent('cli command import:generate') console.log(`Generating users`); @@ -54,12 +81,19 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou // noinspection JSUnusedGlobalSymbols export const importGenerate = new Command('import:generate') .description('Generate sample import data') - .option('-n, --numberOfFiles ', 'The number of files.') - .option('-c, --countPerFile ', 'The count of records per file.') - .option('-a, --applicationId ', 'The application to register users to.') - .option('-g, --groupId ', 'The group id to add users to.') - .option('-d, --tmpDir ', 'The directory to write files to.', 'tmp') - .option('-f, --filePrefix ', 'The file prefix for output files.', 'output') + .option('-n, --number-of-files ', 'The number of files.') + .option('-c, --count-per-file ', 'The count of records per file.') + .option('-a, --application-id ', 'The application to register users to.') + .option('-g, --group-id ', 'The group id to add users to.') + .option('-d, --tmp-dir ', 'The directory to write files to.', 'tmp') + .option('-f, --file-prefix ', 'The file prefix for output files.', 'output') + // Deprecated camelCase aliases — hidden from help, kept for backward compatibility + .addOption(new Option('--numberOfFiles ', 'Deprecated: use --number-of-files').hideHelp()) + .addOption(new Option('--countPerFile ', 'Deprecated: use --count-per-file').hideHelp()) + .addOption(new Option('--applicationId ', 'Deprecated: use --application-id').hideHelp()) + .addOption(new Option('--groupId ', 'Deprecated: use --group-id').hideHelp()) + .addOption(new Option('--tmpDir ', 'Deprecated: use --tmp-dir').hideHelp()) + .addOption(new Option('--filePrefix ', 'Deprecated: use --file-prefix').hideHelp()) .action(action); diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 206797a..52aaf8c 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -8,11 +8,150 @@ import fs from 'node:fs' import path from "node:path"; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { betaWarning, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; +import { randomUUID } from 'node:crypto'; +import validator from 'validator'; +import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -async function createKickstart(kickstartPath: string, answers: any, newDir: string) { +// --------------------------------------------------------------------------- +// Validation helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** + * Validates an email address. + * @returns `true` if valid, otherwise an error message string. + */ +export function validateEmail(email: string): true | string { + return validator.isEmail(email) ? true : 'Not a valid email address'; +} + +/** + * Validates the admin password. + * @returns `true` if valid, otherwise an error message string. + */ +export function validatePassword(password: string): true | string { + if (password.length === 0) { + return 'Custom password is required'; + } + if (password.length < 8) { + return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)'; + } + return true; +} + +// --------------------------------------------------------------------------- +// Answer resolution (exported for testing) +// --------------------------------------------------------------------------- + +export interface InstallOptions { + adminEmail?: string; + adminPasswordEnv?: string; + applicationName?: string; +} + +export interface InstallAnswers { + email: string; + password: string; + appName: string; +} + +/** + * We need the initial admin's credentials (email and password) and a name for a + * starter app. This will take values from command line params if present, then + * fall back to prompting the user. + * + * If all values are supplied then no prompts are shown. This is useful for + * unattended or agent-driven installs. + * + * Note that the password param names an environment variable to get the password + * from. This is to protect the password from showing up in process lists or being + * written to command line history files. + * + * @param options CLI option values (any subset may be provided). + * @param promptFn Injected prompt function; defaults to `inquirer.prompt`. + * Pass a mock in tests to avoid real TTY interaction. + */ +export async function resolveInstallAnswers( + options: InstallOptions, + promptFn: typeof inquirer.prompt = inquirer.prompt +): Promise { + // --- Resolve email --- + let email: string | undefined; + if (options.adminEmail !== undefined) { + const result = validateEmail(options.adminEmail); + if (result !== true) { + throw new Error(`--admin-email: ${result}`); + } + email = options.adminEmail; + } + + // --- Resolve password --- + let password: string | undefined; + if (options.adminPasswordEnv !== undefined) { + const envValue = process.env[options.adminPasswordEnv]; + if (envValue === undefined) { + throw new Error( + `--admin-password-env: environment variable "${options.adminPasswordEnv}" is not set` + ); + } + const result = validatePassword(envValue); + if (result !== true) { + throw new Error(`--admin-password-env: ${result}`); + } + password = envValue; + } + + // --- Resolve appName --- + let appName: string | undefined = options.applicationName; + + // --- Prompt for any fields not yet resolved --- + const questions: import('inquirer').DistinctQuestion[] = []; + + if (email === undefined) { + questions.push({ + type: 'input', + name: 'email', + message: 'Admin Email Address', + default: 'admin@example.com', + validate: validateEmail, + }); + } + + if (password === undefined) { + questions.push({ + type: 'password', + name: 'password', + message: 'Admin user password', + mask: true, + validate: validatePassword, + }); + } + + if (appName === undefined) { + questions.push({ + type: 'input', + name: 'appName', + message: 'Name your application', + default: 'Example App', + }); + } + + if (questions.length > 0) { + const prompted = await promptFn(questions); + if (email === undefined) email = prompted.email as string; + if (password === undefined) password = prompted.password as string; + if (appName === undefined) appName = prompted.appName as string; + } + + return { email: email!, password: password!, appName: appName! }; +} + +// --------------------------------------------------------------------------- +// Kickstart file generation +// --------------------------------------------------------------------------- + +async function createKickstart(kickstartPath: string, answers: InstallAnswers, newDir: string) { const salt = bcrypt.genSaltSync(10) const saltBase = salt.split('$10$')[1]; const fullHash = bcrypt.hashSync(answers.password, salt) @@ -29,11 +168,15 @@ async function createKickstart(kickstartPath: string, answers: any, newDir: stri fs.writeFileSync(`${newDir}/kickstart/kickstart.json`, JSON.stringify(kickstartObject, null, 2)) } -const action = async function (dir: string) { +// --------------------------------------------------------------------------- +// Command action +// --------------------------------------------------------------------------- + +const action = async function (dir: string, options: InstallOptions) { const dockerInstalled = isDockerInstalled(); const directory = path.resolve(dir) logEvent('cli command kickstart:install') - + betaWarning() try { @@ -53,81 +196,49 @@ const action = async function (dir: string) { console.error(chalk.red(`Can't write to ${parentDir}. Please check permissions on the directory`)) } - inquirer.prompt([ - { - type: 'input', - name: 'email', - message: "Admin Email Address", - default: 'admin@example.com', - validate: function (email) { - return /(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/.test(email) ? true : 'Not a valid email address'; - } - }, - { - type: 'password', - name: 'password', - message: "Admin user password", - mask: true, - validate: (text) => { - if (text.length == 0) { - return 'Custom password is required' - } else if (text.length < 8) { - return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)' - } else { - return true - } - } - }, - { - type: 'input', - name: 'appName', - message: 'Name your application', - default: "Example App" - } - ]) - .then((answers) => { - const spinner = yoctoSpinner({ text: "Building..." }).start() - setTimeout(() => { - // move fusionauth folder to user's project - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - }, 500) - setTimeout(() => { - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - }, 1500) - - setTimeout(() => { - const postgresPass = crypto.randomUUID() - const dbPass = crypto.randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - }, 2500) - - setTimeout(() => { - spinner.success("Done building!\n") - - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) - - }, 3500) - - }).catch((err) => { - console.error(chalk.yellow('Cancelling kickstart installation...')) - }) + let answers: InstallAnswers; + try { + answers = await resolveInstallAnswers(options); + } catch (e: any) { + errorAndExit(e.message ?? String(e)); + return; + } + + const spinner = yoctoSpinner({ text: "Building..." }).start() + + // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: + // - exceptions propagate through the surrounding try/catch + // - step ordering is deterministic regardless of machine speed + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) + + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) + await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) + const postgresPass = randomUUID() + const dbPass = randomUUID() + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) } catch (e) { console.error(e) + // Ensure a failed install (copy, kickstart write, rename, env update, etc.) + // is reflected in the process exit code rather than silently exiting 0. + process.exitCode = 1 } - } export const kickstartInstall = new Command() .command('kickstart:install') .description('Adds a directory with a FusionAuth Docker + Kickstart') .argument('[dir]', 'Optional directory to install FusionAuth', 'fusionauth') - .action((dir) => action(dir)) \ No newline at end of file + .option('--admin-email ', 'Admin user email address (skips prompt)') + .option('--admin-password-env ', 'Name of environment variable containing the admin password (skips prompt)') + .option('--application-name ', 'Application name (skips prompt)') + .action((dir, options) => action(dir, options)) diff --git a/src/commands/kickstart-kill.ts b/src/commands/kickstart-kill.ts index 11e4863..4474ba6 100644 --- a/src/commands/kickstart-kill.ts +++ b/src/commands/kickstart-kill.ts @@ -2,57 +2,53 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import { spawn } from 'node:child_process'; -import { betaWarning, isDockerInstalled, logEvent } from "../utils.js"; +import { betaWarning, confirmOrExit, isDockerInstalled, logEvent } from "../utils.js"; import boxen from "boxen"; -import inquirer from "inquirer"; +// Dependencies below are injectable for testing — avoids real docker/confirm/exit calls +export interface KillDeps { + isDockerInstalled?: typeof isDockerInstalled; + confirmOrExit?: typeof confirmOrExit; + spawn?: typeof spawn; +} + +export const action = async function ({ yes }: { yes: boolean }, deps: KillDeps = {}) { + const checkDocker = deps.isDockerInstalled ?? isDockerInstalled; + const confirm = deps.confirmOrExit ?? confirmOrExit; + const spawnFn = deps.spawn ?? spawn; -const action = async function () { betaWarning(); try { - if (!isDockerInstalled()) throw (chalk.red('Error: You need Docker to run.')) - + if (!checkDocker()) throw (chalk.red('Error: You need Docker to run.')) + if (process.cwd() != process.env.CLI_DIR) throw(chalk.red('Error: Current directory was not kickstarted.')) logEvent('cli command kickstart:kill') - inquirer.prompt([ - { - type: 'confirm', - name: 'confirmation', - message: 'This is a destructive action. Are you sure you want to kill this container?' + await confirm( + "This will run 'docker compose down -v', destroying the container and all database data. This cannot be undone.", + yes + ); + console.log(chalk.yellow('Killing FusionAuth...\n')) + try { + const starting = spawnFn('docker compose down -v', { shell: true, stdio: 'inherit' }) + starting.on('error', e => { + console.error(e) + }) + if (starting?.stdout) { + for await (const data of starting.stdout) { + console.log(`${chalk.green(`FusionAuth:`)} ${data}`); + }; } - ]) - .then(async (answers) => { - if (!answers.confirmation) { - console.log(chalk.yellow('Cancelling the shutdown. The container is still running')) - process.exit() - } - - console.log(chalk.yellow('Killing FusionAuth...\n')) - try { - const starting = spawn('docker compose down -v', { shell: true, stdio: 'inherit' }) - starting.on('error', e => { - console.error(e) - }) - if (starting?.stdout) { - for await (const data of starting.stdout) { - console.log(`${chalk.green(`FusionAuth:`)} ${data}`); - }; - } - - starting.on('close', code => { - console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) - }) - } catch (e) { - console.error(e) - } - }).catch(e => { - console.log(chalk.red("The process exited. Please try again.")) + starting.on('close', code => { + console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) }) + } catch (e) { + console.error(e) + } } catch (err) { console.log(err) @@ -63,4 +59,5 @@ const action = async function () { export const kickstartKill = new Command() .command('kickstart:kill') .description('Runs docker compose down in current directory') - .action(action) + .option('--yes', 'Skip confirmation prompt', false) + .action((options) => action(options)) diff --git a/src/commands/telemetry/telemetry-utils.ts b/src/commands/telemetry/telemetry-utils.ts index d6515ae..76c4291 100644 --- a/src/commands/telemetry/telemetry-utils.ts +++ b/src/commands/telemetry/telemetry-utils.ts @@ -1,12 +1,10 @@ import fs from "node:fs" -import { __dirname } from '../../utils.js' - -import { loadConfig } from "../../utils.js" +import { getConfigDir, loadConfig } from "../../utils.js" export function telemetryUpdate(value: boolean) { let config = loadConfig() config.globalConfig.telemetry = value - fs.writeFileSync(__dirname + '/.fa/config.json', JSON.stringify(config.globalConfig, null, 2)) + fs.writeFileSync(getConfigDir() + '/.fa/config.json', JSON.stringify(config.globalConfig, null, 2)) return config } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..c8358f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,7 +13,7 @@ import { PostHog } from 'posthog-node' import * as dotenv from 'dotenv' -dotenv.config() +dotenv.config({ quiet: true }); export const posthogClient = new PostHog( 'phc_nB6C2uZX2LA6ce6VAaWZxBYPtq1wYH5x8A3n36DaLzQ', @@ -175,6 +175,67 @@ export function errorAndExit(message: string, error?: any) { process.exit(1); } +// Exported for testability — pure logic, no I/O, easy to unit test directly. +export function isConfirmationAccepted(answer: string): boolean { + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + +// Exported for testability — settles the prompt's Promise without needing a real TTY/readline round-trip. +export function handleConfirmationAnswer( + answer: string, + resolve: () => void, + reject: (reason?: any) => void +): void { + if (isConfirmationAccepted(answer)) { + resolve(); + return; + } + console.log('Aborted.'); + process.exit(0); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — reject rather + // than falling through to resolve(), which would incorrectly treat a decline as + // confirmation. + reject(new Error('Aborted by user.')); +} + +/** + * Prompts the user for confirmation before proceeding with a risky operation. + * + * - If `yes` is true, returns immediately (caller has pre-confirmed). + * - If running interactively (both stdin and stdout are TTYs), prints the message + * and prompts [y/N]. Accepts "y" or "yes" (case-insensitive, whitespace trimmed) + * as confirmation; anything else aborts. + * - If not running interactively (agent/script/pipe — e.g. stdin is piped even if + * stdout is a TTY), prints the message and exits with an error instructing the + * caller to pass --yes. + * + * @param message A description of what will happen and why it is risky. + * @param yes The value of the --yes flag from the command options. + */ +export async function confirmOrExit(message: string, yes: boolean): Promise { + if (yes) return; + + console.warn(chalk.yellow(message)); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + errorAndExit('Pass --yes to confirm this operation non-interactively.'); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — throw rather + // than returning normally, which would incorrectly let the caller proceed. + throw new Error('Confirmation required: pass --yes to confirm this operation non-interactively.'); + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve, reject) => { + rl.question('Proceed? [y/N] ', (answer) => { + rl.close(); + handleConfirmationAnswer(answer, resolve, reject); + }); + }); +} + /** * Returns a console log that can be added to a beta feature to warn the user */ @@ -209,6 +270,16 @@ export function isDirEmpty(path: string) { } } +/** + * Returns the base directory used for the global `.fa/config.json` file. + * Defaults to the directory containing this module, but can be overridden + * via FUSIONAUTH_CONFIG_DIR — primarily so tests can point at a real + * temporary directory instead of mocking the filesystem. + */ +export function getConfigDir(): string { + return process.env.FUSIONAUTH_CONFIG_DIR ?? __dirname +} + export function loadConfig() { const defaultConfig = { telemetry: true, @@ -216,10 +287,11 @@ export function loadConfig() { version: "1.0" } - const configPath = __dirname + '/.fa/config.json' + const configDir = getConfigDir() + const configPath = configDir + '/.fa/config.json' try { if (!fs.existsSync(configPath)) { - createConfig(__dirname + '/.fa', defaultConfig) + createConfig(configDir + '/.fa', defaultConfig) } const globalConfig = JSON.parse(fs.readFileSync(configPath).toString()) // TODO: Combine this with a local-project config @@ -304,7 +376,7 @@ export function createConfig(dir: string, configObject: ConfigObject = { id: ran type PropertyToAdd = {[key:string]: any} async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd[]) { const config = loadConfig() - const configPath = __dirname + '/.fa/config.json' + const configPath = getConfigDir() + '/.fa/config.json' let newConfig: any; if (Array.isArray(propertiesToAdd)) { @@ -324,4 +396,4 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} From bd1be904811135a964afda777d0a87422d4ac1ea Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Tue, 22 Sep 2026 09:17:16 -0400 Subject: [PATCH 11/33] fix(update): logs and outputs error for :get --- src/commands/application/get.ts | 41 ++++++++++++++++++++++++++++++ src/commands/application/index.ts | 1 + src/commands/application/update.ts | 20 +++++---------- src/commands/index.ts | 2 +- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index e69de29..6e09289 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -0,0 +1,41 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, logEvent } from '../../utils.js' +import { HTTPClient } from '../../utilities/apply/http-client.js'; +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { inspect } from "node:util"; + +const action = async function (id:string, options: Record): Promise { + + const { + host = 'http://localhost:9011', + key, + filePath = `./${id}.json` + } = options + logEvent('cli application:get') + try { + const fullPath = path.resolve(filePath); + const httpClient = new HTTPClient(host, key); + const response = await httpClient.executeRequest('GET', `/api/application/${id}`) + if (response.status != 200) throw response + + writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) + } catch({body}:any) { + console.log(chalk.red("The request produced the following error:\n"), inspect(body,{showHidden: false, depth: null, colors: true})) + } + + +} + + + export const appGet = new Command() + .command('application:get') + .argument('', "The FusionAuth Application ID to update") + .option('-o, --output ', "Path where the data should be stored") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Updates an application with data provided via a file, a property, or a command flag.') + .action(action) + \ No newline at end of file diff --git a/src/commands/application/index.ts b/src/commands/application/index.ts index fb7048c..e2004a3 100644 --- a/src/commands/application/index.ts +++ b/src/commands/application/index.ts @@ -1 +1,2 @@ export * from "./update.js"; +export * from "./get.js" \ No newline at end of file diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index b74e2c8..52316f7 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -5,7 +5,6 @@ import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; -import { exampleApplicationBody } from "../../utils.js"; function getData(file: string) { const fileLoc = path.resolve(file) @@ -62,24 +61,16 @@ function splitProp(prop: string) { return {key, value} } -const action = async function (options: Record): Promise { +const action = async function (id:string, options: Record): Promise { const { host = 'http://localhost:9011', - key, - id + key } = options const httpClient = new HTTPClient(host, key); try { logEvent('cli application:create') - if (options?.example) { - console.log(chalk.yellow("Generating example file in current directory")) - writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) - console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) - return - } - if (options?.prop) { let data = { application: {}} const splitprops = options.prop.map((prop:string) => splitProp(prop)) @@ -102,14 +93,17 @@ const action = async function (options: Record): Promise { displaySuccess(`Applied patch\n${JSON.stringify(apiBody,null,2)}`) return - } catch (e) { + } catch (e:any) { console.log(e) + if (e?.fieldErrors) { + console.log(e.fieldErrors[0].message) + } } } export const appUpdate = new Command() .command('application:update') - .requiredOption('-i, --id ', "The FusionAuth Application ID to update") + .argument('', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') diff --git a/src/commands/index.ts b/src/commands/index.ts index 16789d9..bbe6f81 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,4 @@ -export * from './application-update/index.js' +export * from './application/index.js' export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; From 7282c2e03b7078c389fe1ec4c45c86e920c1a859 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 23 Sep 2026 09:45:37 -0400 Subject: [PATCH 12/33] feat(update): adds more error handling --- src/commands/application/get.ts | 20 +++++------ src/commands/application/update.ts | 58 ++++++++++++------------------ 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 6e09289..c1b9645 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -25,17 +25,13 @@ const action = async function (id:string, options: Record): Promise } catch({body}:any) { console.log(chalk.red("The request produced the following error:\n"), inspect(body,{showHidden: false, depth: null, colors: true})) } - - } - - - export const appGet = new Command() - .command('application:get') - .argument('', "The FusionAuth Application ID to update") - .option('-o, --output ', "Path where the data should be stored") - .addOption(hostOption) - .addOption(apiKeyOption) - .description('Updates an application with data provided via a file, a property, or a command flag.') - .action(action) +export const appGet = new Command() + .command('application:get') + .argument('', "The FusionAuth Application ID to update") + .option('-o, --output ', "Path where the data should be stored") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Updates an application with data provided via a file, a property, or a command flag.') + .action(action) \ No newline at end of file diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index 52316f7..5070216 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -5,14 +5,18 @@ import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; +import { inspect } from "node:util"; function getData(file: string) { - const fileLoc = path.resolve(file) - - const contentBuffer = readFileSync(fileLoc).toString('utf-8') - const contents = JSON.parse(contentBuffer) + try { + const fileLoc = path.resolve(file) + const contentBuffer = readFileSync(fileLoc).toString('utf-8') + const contents = JSON.parse(contentBuffer) + return contents + } catch(e:any) { + throw new Error(e) + } - return contents } @@ -40,22 +44,6 @@ function displaySuccess(message:string = "Successfully submitted Application upd } - - -export function convertOptionsToApiBody(options: any) { - let body: Record = { - application: {} - } - console.log({ options }) - if (options.redirectUrl) { - if (!body?.application?.oauthConfiguration) body.application.oauthConfiguration = {} - body.application.oauthConfiguration.authorizedRedirectURLs = [options.redirectUrl] - } - - console.log(body) - return body -} - function splitProp(prop: string) { const [key,value] = prop.split("=") return {key, value} @@ -71,6 +59,14 @@ const action = async function (id:string, options: Record): Promise try { logEvent('cli application:create') + if (options?.data) { + const data = await getData(options.data) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + if (response.status !== 200) throw response.body + console.log(chalk.green(`Applied patch\n`), inspect(data, {showHidden: false, depth: null, colors: true})) + return + } + if (options?.prop) { let data = { application: {}} const splitprops = options.prop.map((prop:string) => splitProp(prop)) @@ -81,22 +77,12 @@ const action = async function (id:string, options: Record): Promise return } - if (options?.data) { - const data = await getData(options.data) - await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) - displaySuccess(`Applied patch\n${JSON.stringify(data,null,2)}`) - return - } - - const apiBody = convertOptionsToApiBody(options) - await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) - displaySuccess(`Applied patch\n${JSON.stringify(apiBody,null,2)}`) - return - } catch (e:any) { - console.log(e) - if (e?.fieldErrors) { - console.log(e.fieldErrors[0].message) + if (e?.fieldErrors || e?.generalErrors) { + console.log(chalk.red('An error ocurred. Patch was not applied. Full error:\n')) + console.log(inspect(e, {showHidden: false, depth: null, colors: true})) + } else { + console.log(chalk.red(e)) } } From a46bb0214fd828adff82e7cb766cabcea79778c9 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 23 Sep 2026 09:46:44 -0400 Subject: [PATCH 13/33] feat(update): adds logging for :get --- src/commands/application/get.ts | 17 ++++++++++++++--- src/commands/application/update.ts | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index c1b9645..501f482 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -7,15 +7,16 @@ import { writeFileSync } from "node:fs"; import chalk from "chalk"; import { inspect } from "node:util"; -const action = async function (id:string, options: Record): Promise { +export async function executeGet(id:string, options: Record): Promise { + logEvent("cli application:get") const { host = 'http://localhost:9011', key, filePath = `./${id}.json` } = options - logEvent('cli application:get') - try { + +try { const fullPath = path.resolve(filePath); const httpClient = new HTTPClient(host, key); const response = await httpClient.executeRequest('GET', `/api/application/${id}`) @@ -25,7 +26,17 @@ const action = async function (id:string, options: Record): Promise } catch({body}:any) { console.log(chalk.red("The request produced the following error:\n"), inspect(body,{showHidden: false, depth: null, colors: true})) } + + } + + +const action = async function (id:string, options: Record): Promise { + logEvent('cli application:get') + + executeGet(id, options) +} + export const appGet = new Command() .command('application:get') .argument('', "The FusionAuth Application ID to update") diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index 5070216..a93150d 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -57,7 +57,7 @@ const action = async function (id:string, options: Record): Promise const httpClient = new HTTPClient(host, key); try { - logEvent('cli application:create') + logEvent('cli application:update') if (options?.data) { const data = await getData(options.data) From 2d79a1364ac885f71aed7aade8608fae07255861 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 23 Sep 2026 09:47:28 -0400 Subject: [PATCH 14/33] fix(update): updates language for --data success --- src/commands/application/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index a93150d..1eeecdc 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -63,7 +63,7 @@ const action = async function (id:string, options: Record): Promise const data = await getData(options.data) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) if (response.status !== 200) throw response.body - console.log(chalk.green(`Applied patch\n`), inspect(data, {showHidden: false, depth: null, colors: true})) + console.log(chalk.green(`Applied the following patch\n`), inspect(data, {showHidden: false, depth: null, colors: true})) return } From 37cb7df07678f74b298f3ca92293a44240d91e37 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 23 Sep 2026 09:47:51 -0400 Subject: [PATCH 15/33] fix(update): updates language for prop patch success --- src/commands/application/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index 1eeecdc..d33c141 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -72,7 +72,7 @@ const action = async function (id:string, options: Record): Promise const splitprops = options.prop.map((prop:string) => splitProp(prop)) splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - displaySuccess(`Applied patch\n${JSON.stringify(data,null,2)}`) + displaySuccess(`Applied the following patch\n${JSON.stringify(data,null,2)}`) return } From adbfef40d83a7ac13bb3834e6f606835ce8b9c5b Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Fri, 2 Oct 2026 08:37:05 -0400 Subject: [PATCH 16/33] fix(update): adds error handling and testing for errors --- __tests__/commands/application-get.test.js | 84 +++++++++++ __tests__/commands/application-update.test.js | 142 ++++++++++++++++++ src/commands/application/get.ts | 5 +- src/commands/application/update.ts | 70 ++++++--- 4 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 __tests__/commands/application-get.test.js create mode 100644 __tests__/commands/application-update.test.js diff --git a/__tests__/commands/application-get.test.js b/__tests__/commands/application-get.test.js new file mode 100644 index 0000000..c4d5fe9 --- /dev/null +++ b/__tests__/commands/application-get.test.js @@ -0,0 +1,84 @@ +import { describe, test, beforeEach, afterEach, run } from 'node:test' +import assert, { throws } from 'node:assert/strict' +import nock from 'nock' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { executeGet } from '../../src/commands/application/get.js' +import { chdir, cwd } from 'node:process' + + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' + +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + + + +const BASE_OPTIONS = { + key: API_KEY, + host: FA_HOST, +} + + +describe('application:get options checks', () => { + + test('writes to default file when none provided', async () => { + nock(FA_HOST) + .get(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + if (!fs.existsSync('./tmp')) fs.mkdirSync('./tmp') + const tmp = path.resolve('./tmp') + chdir(tmp) + + try { + await executeGet(APP_ID, BASE_OPTIONS) + const fileExists = fs.existsSync(tmp + '/' + APP_ID + '.json') + assert.equal(fileExists, true, "Response file not created") + } catch(e) { + console.log(e) + } finally { + chdir('../') + fs.rmSync(tmp, {recursive: true}) + nock.en + } + + }) + test('writes to specified file when provided', async () => { + nock(FA_HOST) + .get(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `/test-app-${Date.now()}`)) + try { + await executeGet(APP_ID, {...BASE_OPTIONS, filePath: tmpRoot + "/myFile.json"}) + const fileExists = fs.existsSync(tmpRoot + '/myFile.json') + assert.equal(fileExists, true, "Response file not created") + } catch(e) { + console.log(e) + } finally { + fs.rmSync(tmpRoot, {recursive: true}) + } + + }) + +}) \ No newline at end of file diff --git a/__tests__/commands/application-update.test.js b/__tests__/commands/application-update.test.js new file mode 100644 index 0000000..d39f1ee --- /dev/null +++ b/__tests__/commands/application-update.test.js @@ -0,0 +1,142 @@ +import { describe, test, beforeEach, afterEach, run } from 'node:test' +import assert, { throws } from 'node:assert/strict' +import nock from 'nock' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { action, getData, setNestedProps, splitProp } from '../../src/commands/application/update.js' +import { chdir, cwd } from 'node:process' + + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' + +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + + + +const BASE_OPTIONS = { + key: API_KEY, + host: FA_HOST, +} + +describe("test action function", () => { + + test("no data or props should error", async () => { + await assert.rejects(() => action(APP_ID, {...BASE_OPTIONS})) + }) + + test("Prop option errors with improper syntax", async () => { + await assert.rejects(() => action(APP_ID, {...BASE_OPTIONS, prop: ["something"]})) + }) + + test("errors when response isn't 200", async () => { + const tmp = path.join(os.tmpdir() + "test.json") + const testJSON = { + application: { + authenticationTokenConfiguration: { + enabled: false + }, + baseURL: "http://myurl.com3" + } + } + fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) + + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(400, APP_RESPONSE) + await assert.rejects(() => action((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}), 'prop option fails') + await assert.rejects(() => action((APP_ID), {...BASE_OPTIONS, data: tmp}), 'Data file fails') + + }) + +}) + +describe("test utiltiy functions for update", () => { + test('getData functions', () => { + const tmp = path.join(os.tmpdir() + "test.json") + const testJSON = { + application: { + authenticationTokenConfiguration: { + enabled: false + }, + baseURL: "http://myurl.com3" + } + } + + try { + fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) + const returnedData = getData(tmp) + assert.deepEqual(returnedData, testJSON, "Data doesn't match") + } catch(e) { + console.log(e) + } finally { + fs.rmSync(tmp) + } + }) + test("setNestedProps functions properly", () => { + let obj = {} + const propString = "prop.propString" + const propStringValue = "test" + const propBool = "prop.propBool" + const propBoolValue = false + const propObject = "prop.propObject" + const propObjValue = { name: "value" } + const propArray = "prop.propArray" + const propArrayValue = [1,2,3] + + const expectedObject = { + prop: { + propString: "test", + propBool: false, + propObject: { name: "value" }, + propArray: [1,2,3], + deep: { + deeper: { + deepest: "string" + } + } + } + } + + setNestedProps(obj, propString, propStringValue) + setNestedProps(obj, propBool, propBoolValue) + setNestedProps(obj, propObject, propObjValue) + setNestedProps(obj, propArray, propArrayValue) + setNestedProps(obj, "prop.deep.deeper.deepest", "string") + assert.deepEqual(obj, expectedObject) + }), + test("splitProp works", () => { + const simpleString = 'myVar=myValue' + const actualObj = splitProp(simpleString) + const expectedObj = { key: "myVar", value: "myValue"} + assert.deepEqual(actualObj, expectedObj) + const arrayString = 'myArray=["string", "string2", 1]' + const actualArrayObj = splitProp(arrayString) + const expectedArrayObj = { key: "myArray", value: ["string", "string2", 1]} + assert.deepEqual(actualArrayObj, expectedArrayObj) + const objString = 'myObj={"name": "hi"}' + const actualObjObj = splitProp(objString) + const expectedObjObj = { key: "myObj", value: {name: "hi"}} + assert.deepEqual(actualObjObj, expectedObjObj) + }) +}) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 501f482..fb19d99 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -16,6 +16,7 @@ export async function executeGet(id:string, options: Record): Promi filePath = `./${id}.json` } = options + if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") try { const fullPath = path.resolve(filePath); const httpClient = new HTTPClient(host, key); @@ -23,8 +24,8 @@ try { if (response.status != 200) throw response writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) - } catch({body}:any) { - console.log(chalk.red("The request produced the following error:\n"), inspect(body,{showHidden: false, depth: null, colors: true})) + } catch(e:any) { + console.log(chalk.red("The request produced the following error:\n"), inspect(e,{showHidden: false, depth: null, colors: true})) } diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index d33c141..c350480 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -7,49 +7,63 @@ import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; import { inspect } from "node:util"; -function getData(file: string) { +export function getData(file: string) { try { const fileLoc = path.resolve(file) const contentBuffer = readFileSync(fileLoc).toString('utf-8') const contents = JSON.parse(contentBuffer) return contents - } catch(e:any) { + } catch (e: any) { throw new Error(e) } - - } -function setNestedProps(obj: any, path: string, value: any) { +export function setNestedProps(obj: any, path: string, value: any) { /* Takes object and dynamically applies a property at any depth myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} */ - let schema = obj; + let schema = obj; const pList = path.split('.'); const len = pList.length; - for(var i = 0; i < len-1; i++) { - var elem = pList[i]; - if( !schema[elem] ) schema[elem] = {} - schema = schema[elem]; + for (var i = 0; i < len - 1; i++) { + var elem = pList[i]; + if (!schema[elem]) schema[elem] = {} + schema = schema[elem]; } - schema[pList[len-1]] = value; + schema[pList[len - 1]] = value; return schema } -function displaySuccess(message:string = "Successfully submitted Application update") { +function displaySuccess(message: string = "Successfully submitted Application update") { console.log(chalk.green(message)) } +export function isJSON(string: string) { + try { + JSON.parse(string) + return true + } catch (e) { + return false + } +} + +export function splitProp(prop: string) { + try { + const [key, value] = prop.split("=") -function splitProp(prop: string) { - const [key,value] = prop.split("=") - return {key, value} + if (isJSON(value)) { + return { key, value: JSON.parse(value) } + } + return { key, value } + } catch (e: any) { + throw new Error(e) + } } -const action = async function (id:string, options: Record): Promise { +export const action = async function (id: string, options: Record): Promise { const { host = 'http://localhost:9011', key @@ -59,30 +73,36 @@ const action = async function (id:string, options: Record): Promise try { logEvent('cli application:update') + if (!options.data && !options.prop) throw new Error("No --prop or --data was specified") if (options?.data) { const data = await getData(options.data) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) if (response.status !== 200) throw response.body - console.log(chalk.green(`Applied the following patch\n`), inspect(data, {showHidden: false, depth: null, colors: true})) + console.log(chalk.green(`Applied the following patch\n`), inspect(data, { showHidden: false, depth: null, colors: true })) return } - if (options?.prop) { - let data = { application: {}} - const splitprops = options.prop.map((prop:string) => splitProp(prop)) - splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) + let data = { application: {} } + const splitprops = options.prop.map((prop: string) => { + if (!prop.includes('=')) throw new Error("Property string must be in the syntax =") + return splitProp(prop) + }) + splitprops.forEach((prop: any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - displaySuccess(`Applied the following patch\n${JSON.stringify(data,null,2)}`) + if (response.status !== 200) throw response.body + + displaySuccess(`Applied the following patch\n${JSON.stringify(data, null, 2)}`) return } - } catch (e:any) { + } catch (e: any) { if (e?.fieldErrors || e?.generalErrors) { console.log(chalk.red('An error ocurred. Patch was not applied. Full error:\n')) - console.log(inspect(e, {showHidden: false, depth: null, colors: true})) + console.log(inspect(e, { showHidden: false, depth: null, colors: true })) + } else { - console.log(chalk.red(e)) + throw new Error(e) } } From 8dc2f5749605f94e8e15573838ed016d7d8b10e0 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Fri, 2 Oct 2026 08:46:31 -0400 Subject: [PATCH 17/33] fix(update): adds better success message to :get --- src/commands/application/get.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index fb19d99..e814921 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -24,8 +24,10 @@ try { if (response.status != 200) throw response writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) + console.log(chalk.green(`Response written to `) + fullPath) } catch(e:any) { - console.log(chalk.red("The request produced the following error:\n"), inspect(e,{showHidden: false, depth: null, colors: true})) + console.log(chalk.red("The request produced the following error:\n")) + throw new Error(inspect(e,{showHidden: false, depth: null, colors: true})) } From 73430b7a92cbb05dcf153ac45c74d815b5aff49e Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:30:43 -0600 Subject: [PATCH 18/33] Andrewpai/application create (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding workflow test on pull-request * updating with only main branch for test * Trying out --yes on kickstart:kill * camel-> kebab case, tests * package-lock version update * Address PR review feedback - import-generate: detect deprecated flags in --flag=value form, not just bare --flag - kickstart-install: replace setTimeout-chained install steps with sequential awaited steps so errors propagate through try/catch and ordering is deterministic; also await createKickstart (was previously fire-and-forget) - utils: confirmOrExit now requires both stdin and stdout to be TTYs before treating the session as interactive, and normalizes confirmation input (trims whitespace, accepts y/yes case-insensitively) * Add test coverage for confirmOrExit and kickstart:kill - utils.ts: extract isConfirmationAccepted() as a pure, exported function so the accept/reject decision logic can be unit tested directly without simulating a real TTY - kickstart-kill.ts: export action() and add an injectable deps parameter (isDockerInstalled, confirmOrExit, spawn) so tests can exercise the confirmation gating without touching real docker or exiting the process - add __tests__/utils.test.js covering isConfirmationAccepted and the yes-bypass / non-interactive TTY-detection paths of confirmOrExit - add __tests__/commands/kickstart-kill.test.js covering docker-not-installed, CLI_DIR mismatch, --yes bypass, and confirm-rejected gating paths - wire both new test files into the test and test:unit npm scripts * Add test coverage for import:generate deprecated-flag detection - extract getDeprecatedFlagUsage(argv) as a pure, exported function so the deprecation-detection logic is testable without mocking process.argv or console.warn - export DEPRECATED_FLAGS for use in tests - add __tests__/commands/import-generate.test.js covering: no deprecated flags used, bare --flag and --flag=value forms detected, multiple deprecated flags detected together, new kebab-case form not flagged, and that both the deprecated and current flag spellings populate the same underlying Commander option property - wire the new test file into the test and test:unit npm scripts * checkpointing application create - incomplete * checkpoint * Added next steps. * code review updates * Potential fix for pull request finding 'Use “does not exist” for grammatical agreement' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding 'Update wrapper reference to executeApplicationCreate' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Address PR review feedback - Fix inverted localhost/container-IP fallback order in integration test setup's auth-readiness check - Gate CORS system-configuration mutation behind --yes/confirmOrExit per the Risky Operations Policy - Make --name optional; required only for --profile, preserved from --data JSON unless explicitly overridden - Document that applicationId/clientId are intentionally identical (FusionAuth never accepts clientId as input) - Remove NODE_ENV-conditional exit from executeApplicationCreate so it always returns a result per its documented contract; thread the raw error through to the CLI wrapper for field-level error detail * fix: remove duplicate pull_request trigger key in test.yaml A prior commit's small addition to the now-retired integration-tests.yml (a scoped 'pull_request: branches: [main]' trigger) got merged by git's rename-detection into next's test.yaml during the rebase onto next, producing invalid YAML with two 'pull_request:' keys in the same 'on:' block. test.yaml's existing bare 'pull_request:' trigger already covers all PRs unconditionally, making the scoped duplicate redundant regardless. * fix: address additional PR review feedback - Attribute error prefix only to actual createApplication failures, not CORS setup failures that occur before it's ever called; add regression tests asserting the message content for both cases - Stop the kickstart:install spinner on any build-step failure (fs.cpSync, createKickstart, rename, env write), not just success, so a failed install doesn't leave its animation interval running - Document the new application:create command in README.md * fix: preserve structured FusionAuth errors through rawError - catch blocks around retrieveSystemConfiguration/patchSystemConfiguration/ createApplication now attach the original rejection (typically a FusionAuth ClientResponse carrying fieldErrors/generalErrors) as Error.cause via a small wrapError() helper, instead of discarding it when adding human-readable context - the outer catch in executeApplicationCreate unwraps that cause for ApplicationCreateResult.rawError, so errorAndExit/reportError's dedicated ClientResponse/field-error formatting actually receives the structured error instead of a generic wrapper Error - add a regression test asserting rawError is the original ClientResponse-shaped object, not an instance of Error - fix README's --data example to show --name as optional, matching the actual (preserve-JSON-name-unless-overridden) behavior * fix: tolerate empty response bodies in integration test helper makeApiRequest() called response.json() unconditionally, which throws on successful-but-empty-body responses (e.g. DELETE /api/application returns 200 with no body). This caused deleteApplication()'s soft delete to throw before the hard delete ever ran, and the error was silently swallowed by afterEach's try/catch, leaving every test application behind. Read the body as text first and only parse it as JSON when non-empty. Also remove CONTRIBUTING.md — its content is superseded by the Contributing section already in README.md (picked up from next). * docs: remove dangling CONTRIBUTING.md references CONTRIBUTING.md was removed since its content is superseded by README.md's Contributing section. Update the two remaining comments that referenced it to describe the confirmOrExit()/--yes gating requirement directly instead of pointing at a file that no longer exists. * test: reduce duplication in application-create.test.js - Extract REDIRECT_URI constant for the repeated callback URL literal - Extract spaOptions()/webappOptions() helpers (mirroring the integration test file's baseOptions() pattern) to replace the repeated {...BASE_OPTIONS, profile, redirectUri} boilerplate - Extract mockCompliantSystemConfig() for the repeated already-compliant GET /api/system-configuration nock registration - Merge 'clientSecret is absent for spa profile' and 'result contains name, applicationId, and clientId' into one test — they used identical mocks/options and only differed in which result fields they asserted on No behavioral changes; same assertions, same coverage. * test: reduce duplication in application-create.integration.test.js - Extract REDIRECT_URI constant for the repeated callback URL literal - Extract spaOptions()/webappOptions() helpers wrapping the existing baseOptions() factory, mirroring the unit test file's pattern - Remove redundant explicit tenantId: TENANT_ID in the tenant-header regression test — baseOptions() already defaults to that value - Extract assertCorsHeadersConfigured() for the repeated CORS-headers verification block (spa + native), and apply the enabled:true check to the native test too, which previously lacked it No change in test count or intent; same regression coverage, plus one small strengthening (CORS enabled check now applies to native too). * fix: prevent leaked FusionAuth integration test containers Root cause: startFusionAuthContainer()'s pre-start cleanup used `docker compose ps -q`, which only lists running/restarting containers. A container left in a stopped (but not removed) state by a prior interrupted run was invisible to this check, so cleanup was skipped and the following `docker compose up -d` failed with 'Conflict: container name already in use'. Reproduced this directly (stopped the db container mid-run, confirmed `ps -q` missed it while `ps -aq` found it) before and after the fix. - Use `docker compose ps -aq` so stopped containers are detected - Stop silently swallowing a failure from the actual `docker compose down -v` teardown once containers are confirmed to exist — let it propagate instead of continuing into a doomed `up -d` - Add SIGINT/SIGTERM handlers that attempt teardown before exiting, so a manual Ctrl+C (e.g. during the health-check wait) doesn't skip after()/t.after() and leak a container. Verified with a live foreground SIGINT: handler fires, containers are removed, process exits cleanly - Remove `restart: unless-stopped` from the three services in the test-only docker-compose.yml — on this ephemeral fixture it only risked containers resurrecting themselves after a crash instead of staying stopped - Hoist the repeated composeDir computation to a shared COMPOSE_DIR module constant * build: use glob patterns instead of per-file lists in test scripts Replace the manually-maintained list of every test filename with glob patterns scoped to each test directory, so new test files are picked up automatically without a package.json edit (verified: dropping a scratch test file into __tests__/commands/ changed the count from 134 to 135 with zero script changes). Kept unit/integration as separate steps rather than fully adopting next's single bare `node --test` (which auto-discovers every *.test.js file with no args) because that would run our two Docker-dependent integration tests concurrently by default — verified with a throwaway reproduction (two files racing on the same TCP port) that Node's test runner runs multiple files in parallel processes unless told otherwise. Our two integration tests share the same docker-compose project/container names/ports, so concurrent execution would be flaky at best. - test:unit now globs each unit-test directory instead of naming every file - test:integration now globs __tests__/integration/**/*.test.js in a single invocation with --test-concurrency=1, forcing sequential execution (verified serial, no port conflicts) instead of two separate node invocations chained by && - test is now just test:unit && test:integration - Kept NODE_ENV=test in the script rather than dropping it — apply.ts's executeAction() still relies on it being set to avoid calling process.exit() on its error path, and apply.integration.test.js doesn't set it itself * fix: add --authorized-origin-url to the system CORS allowlist ensureCorsHeaders() enabled CORS and added the required DPoP headers to systemConfiguration.corsConfiguration.allowedHeaders, but never touched corsConfiguration.allowedOrigins — a separate, required field per FusionAuth's own CORS configuration docs. --authorized-origin-url was only being copied into application.oauthConfiguration. authorizedOriginURLs (the hosted-pages iframe/X-Frame-Options allowlist), which is a different setting entirely. Net effect: the command reported CORS as configured, but a spa's actual cross-origin browser requests to the API would still be blocked unless the system allowedOrigins already happened to include that origin. - ensureCorsHeaders() now also accepts the authorized origins and merges any missing ones into allowedOrigins, using exact (case-sensitive) matching and skipping entirely when allowedOrigins already contains '*' - the confirmation-gate decision and prompt message now account for origin changes too, not just headers/enabled — a missing origin alone (with headers/enabled already compliant) now correctly triggers confirmation, closing a gap where it would have silently skipped the patch entirely - applies to both spa and native profiles, consistent with how headers are already handled for both - added unit tests mirroring the existing header-merge coverage (added when missing, no-op when present or when allowedOrigins contains '*', confirmation gate covers origin-only changes) and fixed one pre-existing test whose mock needed updating now that origins are actually checked - added a live integration test asserting the real system configuration's allowedOrigins after a create with --authorized-origin-url * fix: address Copilot's latest review findings - Disable telemetry during local test runs: add FUSIONAUTH_TELEMETRY=false to test:unit/test:integration, mirroring how CI's workflow already sets it. Verified src/.fa/config.json (gitignored, but a real artifact of this gap) was being created and real analytics events were being sent to PostHog on every local test run; confirmed it's no longer created after this change. Also hardened telemetry.test.js's 'tests for logEvent' describe block to explicitly delete FUSIONAUTH_TELEMETRY in beforeEach rather than relying on test declaration order to leave it unset for the one test that requires that -- it previously only passed by coincidence (same latent fragility already present in CI, which sets this var the same way) - Validate --profile against the known profile keys before indexing profileDefaults in executeApplicationCreate(). Direct library callers bypass Commander's .choices() validation; an invalid value previously silently spread "undefined" into an empty object and proceeded to create an application with none of the advertised security defaults while still reporting success - Fix executeApplicationCreate()'s doc comment to accurately describe that confirmOrExit() (invoked via ensureCorsHeaders() for spa/native profiles) can still terminate the process for non-interactive callers without yes=true, or decliners -- consistent with this project's established Risky Operations convention elsewhere (kickstart-kill.ts). No behavior change, just making the contract honest - Update AGENTS.md's stale "No test framework - tests not implemented" line to point at the actual node:test-based suite and npm scripts * fix: handle --redirect-uri/--logout-url/--authorized-origin-url in --data mode --data mode previously silently ignored these three flags entirely -- Commander accepted them, but the custom-mode branch never referenced redirectUri/logoutUrl/authorizedOriginUrl at all, so passing any of them with --data had no effect while the command still reported success. This broke the policy already established (and documented in code comments) for --name earlier in this PR: --data provides "full custom control," and any CLI flag with a corresponding JSON field is an optional override -- it only takes effect when explicitly passed, otherwise the JSON's own value is left untouched. --application-id and --tenant-id already follow this pattern unconditionally in both modes; --name follows it specifically in --data mode. These three flags now do too. Explicitly out of scope: this does not call ensureCorsHeaders() or otherwise mutate system-wide CORS configuration in --data mode -- that remains --profile (spa/native) only, consistent with --data mode's "caller owns their own infrastructure config" principle. Added unit tests covering: JSON values preserved when the flags are omitted, each flag overriding its corresponding JSON field when explicitly provided, and confirming no system-configuration call is made in --data mode even when --authorized-origin-url is passed. * style: pass --env-file .env.test to all docker compose teardown calls docker compose down -v / ps -aq were missing --env-file .env.test, unlike the up -d call, which already passed it. Verified this doesn't currently cause the failure Copilot's review described (KICKSTART_FILE_PATH isn't actually referenced anywhere in docker-compose.yml, and docker compose down -v --dry-run without --env-file still exits 0 with only "variable not set" warnings) -- this exact code path has also torn down real containers successfully many times already in this session's testing. Still worth fixing for consistency with up -d and to silence the warnings; also removes any doubt if the compose file ever adds a variable reference that down/ps genuinely need to resolve correctly. * fix: profile validation accepted inherited Object properties `profile in profileDefaults` checks the full prototype chain, not just own properties, so values like 'toString', 'constructor', and '__proto__' incorrectly passed validation. profileDefaults['toString'] then resolves to the inherited Function (not undefined), and {...profileDefaults['toString']} silently spreads to {} — reaching the exact "empty defaults, no security profile or CORS applied" bug this validation was added to prevent, just via a different vector than the original invalid-string case already covered by tests. Switched to Object.keys(profileDefaults).includes(profile), which only considers own enumerable keys. Added a regression test confirming it's rejected, and verified it reproduces (fails) without the fix. Also updated the README's --profile example to mention --authorized-origin-url and when it's needed, since enabling CORS headers alone doesn't add any origin to the system allowlist. * fix: restrict automatic CORS configuration to --profile spa only CORS is purely a browser-enforced mechanism; native apps don't make requests through a browser's CORS preflight/enforcement at all, so FusionAuth's system-wide CORS allowlist has no effect on them. --profile native was unnecessarily requiring system-configuration permissions, prompting for --yes, and mutating a global security setting (CORS enabled/headers/allowed origins) for no actual benefit. Removed native from the ensureCorsHeaders() trigger condition, updated all related comments/JSDoc/CLI help text, and flipped the native integration test to assert system CORS configuration is left untouched (comparing against the captured baseline) instead of asserting it was configured. Added a dedicated unit test confirming native does not call /api/system-configuration at all, mirroring the existing webapp test. Verified via a full local docker-based integration run. Also fixed a misleading comment on defaultRefreshTokenPolicy: it described timeToLiveInSeconds as "the per-profile difference" in the refresh token policy, but that field is actually the access token (JWT) lifetime, set separately in jwtConfiguration — not part of the refresh token usage/expiration policy at all. * fix: resolve kickstart resources dir correctly when run from source `npm start` runs src/index.ts directly via tsx, skipping the build's copy-files step. kickstart-install.ts read resource files from `${__dirname}/resources/...`, which only exists post-build (resources get copied to dist/commands/resources alongside the compiled command); in the source tree, resources actually live at src/resources, one level up from src/commands. As a result, `npm start -- kickstart:install` failed with a missing resource path. Added resolveResourcesDir(), which checks the dist layout first (__dirname/resources) and falls back to the src layout (__dirname/../resources), throwing a clear error if neither exists. Verified both layouts resolve correctly (manually, and via a new unit test), and confirmed the full build + unit + integration suite still passes. * fix: restore build-first npm start script package.json's "start" script was "node --import=tsx src/index.ts" on this branch, but next's canonical value is "npm run build && node dist/index.js" — this was an incorrect merge conflict resolution during the earlier rebase onto next, which dropped the build step and caused the resource-path regression fixed in 52ae42e. That commit's resolveResourcesDir() fallback remains as a defensive improvement for any other run-from-source scenario, but this restores the actual root cause: npm start building and running from dist/, matching next and ensuring resources are always copied before the CLI needs them. * fix: validate --data JSON is a non-null, non-array object JSON.parse can return null, arrays, or primitives, but parseData() cast the result straight to Application unchecked. Traced the actual failure modes: --data 'null' crashed downstream with an opaque "Cannot read properties of null (reading 'id')" TypeError; arrays and primitives silently passed through property assignments and produced nonsensical API payloads sent to FusionAuth, surfacing as confusing server-side errors instead of a clear client-side validation message. Added a shape check right after JSON.parse, throwing a clear Error consistent with parseData()'s other validation errors. Added three unit tests covering null/array/primitive --data input. Also fixed two stale comments: - A test comment claiming confirmOrExit() "never exits the process itself" — this directly contradicted the JSDoc on executeApplicationCreate (and the earlier fix in 7bb5071): production calls CAN still exit via confirmOrExit(); only this specific test's mocked process.exit turns that into a returned result. - The resolveResourcesDir() JSDoc (from 52ae42e) describing its src/ layout fallback as "npm start running this file via tsx", which my very next commit (bd81c93, restoring the build-first start script) made inaccurate. Reworded to describe direct source execution generically, independent of npm start. * docs: clarify --authorized-origin-url CORS scope in --help text The old text ('for CORS') implied this flag configures cross-origin API access in every mode, but system CORS is only touched for --profile spa. In --data, native, and webapp modes it only sets application.oauthConfiguration.authorizedOriginURLs, a separate application-level allowlist. Reworded to make the scope explicit. * fix: add Content-Type to required CORS headers, dedupe error output Content-Type is only CORS-safelisted for application/x-www-form-urlencoded, multipart/form-data, or text/plain -- not application/json. A SPA sending JSON would still fail preflight after this command reported CORS as "configured", since Content-Type wasn't in the guaranteed header set. Added it to REQUIRED_CORS_HEADERS and updated the comments that described these as "DPoP-related" headers (Content-Type is about JSON bodies not being safelisted, not DPoP specifically). Updated test fixtures that previously hardcoded the old 3-header "fully compliant" set, and the integration test's local REQUIRED_CORS_HEADERS constant, so they continue to validate the correct full set. Verified via a full local docker-based integration run. Also fixed unwrapError() printing the same error message twice. Direct, never-wrapped Errors (e.g. parseData()'s validation errors) have no distinct .cause, so unwrapError() fell back to returning the same Error object as rawError -- which errorAndExit()/reportError() then printed a second time via its generic 'message' in error branch. Reproduced this empirically before and after the fix. unwrapError() now returns undefined in that case, while still preserving a genuinely-wrapped error's distinct cause, or a rejection that was never an Error at all (e.g. a raw ClientResponse-shaped object). Added a regression test asserting rawError is undefined for a direct validation error. * test: exercise all resolveResourcesDir() branches, including dist/ The existing test imported src/commands/kickstart-install.js via tsx, so __dirname was always .../src/commands for the whole test run -- meaning the dist-layout branch (and the error-throw path) had zero coverage, despite the test's comment claiming both layouts were covered. Added a baseDir parameter to resolveResourcesDir() (defaulting to the real __dirname, so production behavior is unchanged) so tests can exercise all three outcomes -- dist found, src fallback found, neither found -- against controlled, synthetic temp directories instead of depending on the real repo's build state. Also added a separate test that imports the actual compiled dist/commands/kickstart-install.js and verifies resolveResourcesDir() resolves correctly against the real build output, confirming the copy-files build step actually produces a working dist/commands/resources directory. Skips gracefully (not fails) when dist/ hasn't been built yet, so test:unit still works without requiring a build first -- meaningful in CI, which always builds before testing. * fix: resolve container ID via Compose, fix path encoding for spaces Two previously-missed findings from the same review, neither acted on across two review cycles -- not a deliberate decision, just missed. CONTAINER_NAME hard-coded Docker Compose's default generated container name ('{project}-{service}-{index}'). If COMPOSE_PROJECT_NAME is set, the real container name differs, both docker inspect calls silently fail (caught by empty catch blocks), and the bridge-IP fallback this PR added is defeated without any visible error. Replaced with resolveContainerId(), which resolves the real ID via `docker compose ps -q fusionauth`, independent of naming conventions. Verified end-to-end via a full local docker-based integration run -- the bridge-IP fallback message still appears correctly, confirming the dynamic resolution works. COMPOSE_DIR used new URL(...).pathname, which leaves special characters like spaces percent-encoded (e.g. '%20') rather than decoding them -- not a valid filesystem path component. Verified empirically that fileURLToPath() correctly decodes it instead. Also replaced the `cd ${COMPOSE_DIR} && ...` string-concatenation pattern (5 call sites) with execAsync(cmd, { cwd: COMPOSE_DIR }), avoiding shell-quoting issues with the path entirely rather than just moving them around. * fix: dedupe authorized origins, clean up test temp directories Duplicate --authorized-origin-url values were only ever compared against the pre-existing system CORS allowlist, not against each other, so passing the same origin twice wrote a duplicate entry into the system-wide CORS configuration. Deduped the supplied origins via [...new Set(authorizedOrigins)] before filtering. Verified empirically before/after, and added a regression test asserting the origin appears exactly once in the PATCH payload. Also fixed resolveResourcesDir()'s unit tests leaking temp directories on every run -- mkTempDir() created a real directory under the OS temp dir in each of the 4 tests but never removed any of them. Confirmed this was a real, accumulating leak: found 16 leftover directories from prior test runs still on disk. Now tracks created dirs and removes them in afterEach; verified a fresh run leaves zero behind. * fix: exit immediately on repeated termination signals during teardown Once a SIGINT/SIGTERM listener is registered, Node no longer applies its default "a second Ctrl+C just kills the process" behavior on its own -- the listener has full responsibility. The early `return` on a repeated signal while handlingTerminationSignal was already true meant that if forceTeardown()'s `docker compose down -v` call hung (it has no timeout, unlike every other network call in this file), every subsequent Ctrl+C/SIGTERM was silently swallowed, leaving no way to interrupt the process short of `kill -9` from another terminal. Verified both the bug and the fix with a standalone repro harness simulating a permanently-hung teardown: the old logic left the process running indefinitely after a second SIGINT; the new logic force-exits with the expected code (130/143) immediately. Also ran the full integration suite to confirm no regression in normal (non-hung) teardown. * fix: show real error messages instead of "[object Object]" Root cause: the FusionAuth SDK rejects with a ClientResponse instance, which does NOT extend Error. All three wrapError() call sites in application-create.ts built their message via "e instanceof Error ? e.message : String(e)" -- since e is never instanceof Error for a real SDK rejection, this always fell to String(e), which for a plain class instance produces the literal "[object Object]". Reproduced the exact reported repro (creating an application with a duplicate name) character for character before fixing, and confirmed the new test fails against the old code. Added describeError() to utils.ts, reusing the existing isClientResponse()/isErrors() guards to correctly unpack fieldErrors/generalErrors, a nested network-level Error, or fall back to "HTTP " -- instead of duplicating that logic locally. Hardened isClientResponse()/isErrors() to not throw on null/undefined input, now that they're used more broadly via describeError(). Used describeError() at all three wrapError() sites. Also replaced reportError()'s final fallback (JSON.stringify via toJson) with util.inspect, which handles circular references and non-JSON-serializable values gracefully instead of throwing or silently dropping them, for genuinely unknown error shapes. Added betaWarning() to application:create's action() (matching the existing convention used by all kickstart:* commands), updated the README's new Applications heading to "(beta)", and added blank-line spacing before the success message and before the Next Steps box per review feedback. * fix: remove redundant error detail printed twice on CLI failure f54f856 fixed "[object Object]" by baking describeError()'s full detail into wrapError()'s message, which became ApplicationCreateResult .error. But reportError() already does its own unwrapping of rawError to print structured fieldErrors/generalErrors detail -- so once .error also carried that same detail, the CLI printed it twice (once as the flattened message, once via reportError's own per-field breakdown). The redundancy was a symptom of putting a presentation-layer concern (a fully flattened, human-readable string) into a data field. Reverted wrapError()'s message construction to short, static, per-stage labels ("Error creating application", etc.) with no appended detail -- this also means the original [object Object] bug can no longer occur by construction, since the message no longer attempts to coerce the rejection into a string at all. The real detail now flows through rawError alone, and reportError()'s existing (unchanged) formatting handles it correctly for both single- and multi-error cases, with no new branching logic needed anywhere. describeError() is now unused in production code (confirmed via repo-wide search) -- removed it from utils.ts along with its tests. Updated the one test that asserted on .error's flattened detail to instead assert structurally on rawError.exception, which is more robust and doesn't couple the test to any particular string-formatting choice. Verified against the real compiled CLI with both a single-field-error and a multi-field-error mocked response: single case now prints exactly one line of detail (previously two identical lines); multi case correctly prints each field error on its own line, matching the original pre-regression behavior. --------- Co-authored-by: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AGENTS.md | 9 +- README.md | 3 + __tests__/commands/application-create.test.js | 933 ++++++++++++++++++ __tests__/commands/kickstart-install.test.js | 123 +++ .../application-create.integration.test.js | 263 +++++ .../docker-compose.yml | 3 - __tests__/integration/setup.js | 302 +++++- __tests__/telemetry/telemetry.test.js | 8 + __tests__/utils.test.js | 21 +- package-lock.json | 4 +- package.json | 6 +- src/commands/application-create.ts | 502 ++++++++++ src/commands/import-generate.ts | 2 +- src/commands/index.ts | 1 + src/commands/kickstart-install.ts | 79 +- src/index.ts | 1 + src/utils.ts | 14 +- 17 files changed, 2201 insertions(+), 73 deletions(-) create mode 100644 __tests__/commands/application-create.test.js create mode 100644 __tests__/integration/application-create/application-create.integration.test.js create mode 100644 src/commands/application-create.ts diff --git a/AGENTS.md b/AGENTS.md index 8649667..3b6b715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,12 @@ -# FusionAuth CLI - Agent Guidelines +# FusionAuth CLI +FusionAuth CLI is a command-line tool for working with the FusionAuth CIAM platform. + +# Guidelines ## Build Commands - Build: `npm run build` (compiles TypeScript to `./dist/`) - No lint command configured -- No test framework - tests not implemented +- Tests use Node's built-in test runner (`node:test`); run `npm run test:unit` for fast unit tests or `npm test` for the full suite, including Docker-based integration tests under `__tests__/integration/`. ## Code Style Guidelines @@ -37,4 +40,4 @@ - Command definitions use Commander.js with fluent API - JSDoc comments for function documentation - Async/await for asynchronous operations -- Template literals for string interpolation \ No newline at end of file +- Template literals for string interpolation diff --git a/README.md b/README.md index 20cbbf6..cb0a684 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ fusionauth --help; Currently, the CLI supports the following commands: - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. +- Applications (beta) + - `fusionauth application:create --name --profile --redirect-uri [--authorized-origin-url ]` - Create an application in one of a few pre-defined, standard security profiles (spa, native, or webapp), automatically configuring the associated OAuth/JWT settings (and CORS headers, for spa — native apps don't go through a browser's CORS enforcement, so this is skipped for native). `--authorized-origin-url` is required if the app will make cross-origin requests from the browser, since enabling CORS alone doesn't allow any origin through — the origin still needs to be added to the system's CORS allowlist. + - `fusionauth application:create [--name ] --data ` - Create an application from a full custom JSON configuration, for cases the standard profiles don't cover. The JSON's own `name` field is used unless `--name` is explicitly passed, in which case it overrides the JSON. - Emails - `fusionauth email:download` - Download a specific template or all email templates from a FusionAuth server. - `fusionauth email:duplicate` - Duplicate an email template locally. diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js new file mode 100644 index 0000000..52ae641 --- /dev/null +++ b/__tests__/commands/application-create.test.js @@ -0,0 +1,933 @@ +import { describe, test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import nock from 'nock' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { executeApplicationCreate } from '../../src/commands/application-create.js' + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' +const REDIRECT_URI = 'https://example.com/callback' + +const BASE_OPTIONS = { + name: 'Test App', + key: API_KEY, + host: FA_HOST, +} + +function spaOptions(overrides = {}) { + return { ...BASE_OPTIONS, profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides } +} + +function webappOptions(overrides = {}) { + return { ...BASE_OPTIONS, profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides } +} + +// Minimal successful createApplication response +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + +// Minimal successful createApplication response with client secret (webapp) +const APP_RESPONSE_WITH_SECRET = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + clientSecret: 'super-secret', + }, + }, +} + +// Minimal successful system-configuration response (CORS already correct) +function systemConfigResponse(overrides = {}) { + return { + systemConfiguration: { + corsConfiguration: { + enabled: true, + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], + ...overrides, + }, + }, + } +} + +// Registers a GET /api/system-configuration mock reporting CORS as already +// compliant — used by every test where no CORS mutation should occur. +function mockCompliantSystemConfig() { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) +} + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +// --------------------------------------------------------------------------- +// Mode validation +// --------------------------------------------------------------------------- + +describe('mode validation', () => { + test('both --profile and --data provided returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...spaOptions(), + data: '{"name":"x"}', + }) + assert.equal(result.success, false) + assert.match(result.error, /mutually exclusive/) + }) + + test('neither --profile nor --data provided returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + }) + assert.equal(result.success, false) + assert.match(result.error, /required/) + }) + + test('--profile without --redirect-uri returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'spa', + }) + assert.equal(result.success, false) + assert.match(result.error, /--redirect-uri is required/) + }) + + test('--profile without --name returns error without making API calls', async () => { + const { name, ...optionsWithoutName } = spaOptions() + const result = await executeApplicationCreate(optionsWithoutName) + assert.equal(result.success, false) + assert.match(result.error, /--name is required/) + }) + + test('an invalid --profile value returns a clear error without making API calls', async () => { + // Direct library callers bypass Commander's .choices() validation, so + // executeApplicationCreate() must validate this itself rather than + // silently spreading `undefined` into an empty application object. + const result = await executeApplicationCreate(spaOptions({ profile: 'not-a-real-profile' })) + assert.equal(result.success, false) + assert.match(result.error, /--profile must be one of/) + assert.match(result.error, /spa/) + assert.match(result.error, /native/) + assert.match(result.error, /webapp/) + }) + + test('a --profile value that is an inherited Object property is rejected', async () => { + // `profile in profileDefaults` would incorrectly accept values like + // 'toString' or 'constructor', since `in` checks the prototype chain, + // not just own properties. profileDefaults['toString'] then resolves + // to the inherited Function, and {...profileDefaults['toString']} + // silently produces {} — reaching the exact "empty defaults, no + // security profile applied" bug this validation exists to prevent. + const result = await executeApplicationCreate(spaOptions({ profile: 'toString' })) + assert.equal(result.success, false) + assert.match(result.error, /--profile must be one of/) + }) +}) + +// --------------------------------------------------------------------------- +// --data parsing +// --------------------------------------------------------------------------- + +describe('--data parsing', () => { + test('inline JSON is parsed and sent', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ oauthConfiguration: { enabledGrants: ['authorization_code'] } }), + }) + assert.equal(result.success, true) + }) + + test('@file.json is read and parsed', async () => { + const tmp = path.join(os.tmpdir(), `test-app-${Date.now()}.json`) + fs.writeFileSync(tmp, JSON.stringify({ oauthConfiguration: {} })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + try { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: `@${tmp}`, + }) + assert.equal(result.success, true) + } finally { + fs.unlinkSync(tmp) + } + }) + + test('malformed inline JSON returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '{not valid json', + }) + assert.equal(result.success, false) + assert.match(result.error, /JSON/) + }) + + test('--data "null" returns a clear validation error without making API calls', async () => { + // JSON.parse('null') succeeds (it's valid JSON), so this isn't caught + // by the malformed-JSON case above. Without a shape check, this would + // otherwise surface as a confusing downstream TypeError instead. + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: 'null', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('--data as a JSON array returns a clear validation error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '[1,2,3]', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('--data as a JSON primitive returns a clear validation error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '42', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('missing @file returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '@/does/not/exist.json', + }) + assert.equal(result.success, false) + assert.match(result.error, /Error reading --data file/) + }) + + test('--data mode preserves the JSON name when --name is omitted (full custom control)', async () => { + const { name, ...optionsWithoutName } = BASE_OPTIONS + + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.name, 'Name From JSON') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...optionsWithoutName, + data: JSON.stringify({ name: 'Name From JSON', oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) + + test('--data mode overrides the JSON name when --name is explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.name, 'Test App') // BASE_OPTIONS.name + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ name: 'Name From JSON', oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) + + test('--data mode preserves the JSON oauthConfiguration when the override flags are omitted', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.deepEqual(oauth.authorizedRedirectURLs, ['https://from-json.example.com/cb']) + assert.equal(oauth.logoutURL, 'https://from-json.example.com/logout') + assert.deepEqual(oauth.authorizedOriginURLs, ['https://from-json.example.com']) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ + oauthConfiguration: { + authorizedRedirectURLs: ['https://from-json.example.com/cb'], + logoutURL: 'https://from-json.example.com/logout', + authorizedOriginURLs: ['https://from-json.example.com'], + }, + }), + }) + assert.equal(result.success, true) + }) + + test('--redirect-uri overrides the JSON authorizedRedirectURLs when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.deepEqual(body.application.oauthConfiguration.authorizedRedirectURLs, [REDIRECT_URI]) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + redirectUri: [REDIRECT_URI], + data: JSON.stringify({ oauthConfiguration: { authorizedRedirectURLs: ['https://from-json.example.com/cb'] } }), + }) + assert.equal(result.success, true) + }) + + test('--logout-url overrides the JSON logoutURL when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.oauthConfiguration.logoutURL, 'https://override.example.com/logout') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + logoutUrl: 'https://override.example.com/logout', + data: JSON.stringify({ oauthConfiguration: { logoutURL: 'https://from-json.example.com/logout' } }), + }) + assert.equal(result.success, true) + }) + + test('--authorized-origin-url overrides the JSON authorizedOriginURLs when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.deepEqual(body.application.oauthConfiguration.authorizedOriginURLs, ['https://override.example.com']) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + authorizedOriginUrl: ['https://override.example.com'], + data: JSON.stringify({ oauthConfiguration: { authorizedOriginURLs: ['https://from-json.example.com'] } }), + }) + assert.equal(result.success, true) + }) + + test('--data mode does not mutate system CORS configuration (unlike --profile spa)', async () => { + // Only register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + authorizedOriginUrl: ['https://override.example.com'], + data: JSON.stringify({ oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Profile defaults — request body assertions +// --------------------------------------------------------------------------- + +describe('profile defaults', () => { + test('spa profile sends correct oauthConfiguration and jwtConfiguration', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + const jwt = body.application.jwtConfiguration + assert.deepEqual(oauth.enabledGrants, ['authorization_code', 'refresh_token']) + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + assert.equal(oauth.requireClientAuthentication, false) + assert.equal(oauth.generateRefreshTokens, true) + assert.equal(oauth.requireRegistration, true) + assert.deepEqual(oauth.authorizedRedirectURLs, [REDIRECT_URI]) + assert.equal(jwt.enabled, true) + assert.equal(jwt.timeToLiveInSeconds, 300) + assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(jwt.refreshTokenExpirationPolicy, 'SlidingWindow') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) + + test('native profile sends same oauth defaults as spa', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + assert.equal(oauth.requireRegistration, true) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'native', + redirectUri: ['myapp://callback'], + }) + assert.equal(result.success, true) + }) + + test('native profile does not call system-configuration', async () => { + // Native apps don't go through a browser's CORS enforcement, so + // --profile native should not touch CORS at all, unlike spa. Only + // register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'native', + redirectUri: ['myapp://callback'], + }) + assert.equal(result.success, true) + }) + + test('webapp profile sends confidential client settings', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + const jwt = body.application.jwtConfiguration + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'NotRequiredWhenUsingClientAuthentication') + assert.equal(oauth.clientAuthenticationPolicy, 'Required') + assert.equal(oauth.requireClientAuthentication, true) + assert.equal(oauth.requireRegistration, true) + assert.deepEqual(oauth.enabledGrants, ['authorization_code', 'refresh_token']) + assert.equal(jwt.timeToLiveInSeconds, 3600) + assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(jwt.refreshTokenExpirationPolicy, 'SlidingWindow') + return true + }) + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('webapp profile does not call system-configuration', async () => { + // Only register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + }) + + test('optional profile options are included when provided', async () => { + // allowedOrigins already includes the origin below so this test can + // focus on the oauthConfiguration fields without also triggering the + // CORS-origin confirmation gate (covered separately). + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://example.com'] })) + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.deepEqual(oauth.authorizedOriginURLs, ['https://example.com']) + assert.equal(oauth.logoutURL, 'https://example.com/logout') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://example.com'], + logoutUrl: 'https://example.com/logout', + })) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// ID overrides +// --------------------------------------------------------------------------- + +describe('ID overrides', () => { + test('--application-id is sent in the request URL and body', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post(`/api/application/${APP_ID}`, (body) => { + assert.equal(body.application.id, APP_ID) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ applicationId: APP_ID })) + assert.equal(result.success, true) + assert.equal(result.applicationId, APP_ID) + }) + + test('--application-id overrides id in --data', async () => { + const overrideId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + + nock(FA_HOST) + .post(`/api/application/${overrideId}`, (body) => { + assert.equal(body.application.id, overrideId) + return true + }) + .reply(200, { application: { id: overrideId, name: 'Test App', oauthConfiguration: { clientId: overrideId } } }) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ id: 'original-id', name: 'Test App' }), + applicationId: overrideId, + }) + assert.equal(result.success, true) + assert.equal(result.applicationId, overrideId) + }) + + test('--tenant-id overrides tenantId in --data', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.tenantId, TENANT_ID) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ tenantId: 'original-tenant' }), + tenantId: TENANT_ID, + }) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Regression: tenant header scoping +// The X-FusionAuth-TenantId header must be absent on /api/system-configuration +// but present on /api/application when --tenant-id is supplied. +// --------------------------------------------------------------------------- + +describe('regression: tenant header scoping', () => { + test('X-FusionAuth-TenantId is absent on system-configuration call', async () => { + nock(FA_HOST, { + badheaders: ['X-FusionAuth-TenantId'], // fails if header IS present + }) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ tenantId: TENANT_ID })) + assert.equal(result.success, true) + }) + + test('X-FusionAuth-TenantId is present on createApplication call when --tenant-id supplied', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST, { + reqheaders: { 'x-fusionauth-tenantid': TENANT_ID }, + }) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ tenantId: TENANT_ID })) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Regression: error attribution +// A failure in ensureCorsHeaders must not be reported as "Error creating application". +// --------------------------------------------------------------------------- + +describe('regression: error attribution', () => { + test('system-configuration 401 returns error before createApplication is called', async () => { + // Register system-config to return 401 + nock(FA_HOST) + .get('/api/system-configuration') + .reply(401) + + // Do NOT register /api/application — if it were called, afterEach isDone() would pass + // incorrectly. We rely on the nock.pendingMocks() check being empty as the success signal, + // but more importantly we assert result.success is false here. + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, false) + // createApplication was never called, so the message must not be misattributed + // to it — it should describe the actual (CORS retrieval) failure. + assert.match(result.error, /Error retrieving system configuration/) + assert.doesNotMatch(result.error, /Error creating application/) + }) + + test('CORS patch failure returns error before createApplication is called', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) // missing headers → triggers patch + + nock(FA_HOST) + .patch('/api/system-configuration') + .reply(403) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, false) + assert.match(result.error, /Error updating CORS configuration/) + assert.doesNotMatch(result.error, /Error creating application/) + }) + + test('createApplication failure is correctly attributed to application creation', async () => { + // No CORS-related calls for webapp — createApplication is the only call made. + nock(FA_HOST) + .post('/api/application/') + .reply(500, {}) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + assert.match(result.error, /Error creating application/) + }) + + test('rawError preserves the original structured FusionAuth error rather than a generic wrapper', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(400, { fieldErrors: { name: [{ message: 'is required' }] } }) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + // rawError must be the original FusionAuth ClientResponse-shaped rejection + // (so errorAndExit/reportError can format fieldErrors/generalErrors), + // not the generic Error used for the human-readable `error` message. + assert.equal(result.rawError instanceof Error, false) + assert.equal(result.rawError.statusCode, 400) + assert.deepEqual(result.rawError.exception, { fieldErrors: { name: [{ message: 'is required' }] } }) + }) + + test('error message describes a duplicate-name failure instead of showing "[object Object]"', async () => { + // Real-world repro: creating an application with a name that already + // exists. FusionAuth's SDK rejects with a ClientResponse instance, + // which does NOT extend Error, so the old `e instanceof Error + // ? e.message : String(e)` always fell to String(e) — producing the + // literal, unhelpful "Error creating application: [object Object]". + nock(FA_HOST) + .post('/api/application/') + .reply(400, { generalErrors: [{ code: '[duplicate]', message: 'An Application with id or name [MyApp] already exists.' }] }) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + assert.doesNotMatch(result.error, /\[object Object\]/) + // The detailed FusionAuth error lives in rawError (structured), not + // flattened into `error` — formatting that for display is a + // presentation-layer concern (see reportError()), not something baked + // into the result data itself. + assert.deepEqual(result.rawError.exception, { + generalErrors: [{ code: '[duplicate]', message: 'An Application with id or name [MyApp] already exists.' }], + }) + }) + + test('rawError is undefined for a direct, never-wrapped validation error', async () => { + // parseData() throws a plain Error directly (not via wrapError()), so + // it has no distinct .cause. Its message is already captured in + // `error` — if rawError also returned the same Error object, + // errorAndExit()/reportError() would print that message a second time. + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '{not valid json', + }) + assert.equal(result.success, false) + assert.equal(result.rawError, undefined) + }) +}) + +// --------------------------------------------------------------------------- +// CORS header management +// --------------------------------------------------------------------------- + +describe('CORS header management', () => { + test('no PATCH when all required headers already present (any casing)', async () => { + // Only GET is registered — a PATCH would cause nock to throw + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + allowedHeaders: ['DPoP', 'authorization', 'ACCEPT', 'Content-Type'], + })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) + + test('PATCH adds only missing headers, preserving existing ones', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: true, + allowedHeaders: ['Authorization', 'Content-Type'], // dpop and Accept missing + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const headers = body.systemConfiguration.corsConfiguration.allowedHeaders + assert(headers.includes('Authorization'), 'should preserve existing Authorization') + assert(headers.includes('Content-Type'), 'should preserve existing Content-Type') + assert(headers.some(h => h.toLowerCase() === 'dpop'), 'should add dpop') + assert(headers.some(h => h.toLowerCase() === 'accept'), 'should add Accept') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH sets enabled:true when CORS is disabled', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: false, + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + assert.equal(body.systemConfiguration.corsConfiguration.enabled, true) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH adds --authorized-origin-url to the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://existing.example.com'] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + assert(origins.includes('https://existing.example.com'), 'should preserve existing origin') + assert(origins.includes('https://myapp.example.com'), 'should add the new origin') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('duplicate --authorized-origin-url values are not duplicated in the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + const count = origins.filter(o => o === 'https://myapp.example.com').length + assert.equal(count, 1, `'https://myapp.example.com' should appear exactly once, got ${count}`) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + // Same origin supplied twice via --authorized-origin-url. + authorizedOriginUrl: ['https://myapp.example.com', 'https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH when --authorized-origin-url is already in the system CORS allowlist', async () => { + // Headers/enabled already compliant too — only origins differ from the + // baseline, so this also exercises the "would otherwise early-return" + // path now correctly checking origins as well. + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://myapp.example.com'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH for origins when allowedOrigins already contains "*"', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['*'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('--authorized-origin-url is not required — no origin changes attempted when omitted', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Confirmation gate (--yes) for CORS mutation +// Mutating system-wide CORS configuration must be gated behind +// confirmOrExit()/--yes. +// --------------------------------------------------------------------------- + +describe('confirmation gate for CORS mutation', () => { + test('non-interactive without --yes aborts before patching CORS or creating the application', async (t) => { + // process.exit is mocked so confirmOrExit() throws instead of actually + // exiting (see utils.ts docstring) — the throw is caught by + // executeApplicationCreate's try/catch and returned as a normal result. + // In production (unmocked), confirmOrExit() can still exit the process + // directly for a non-interactive caller without yes=true — see the + // documented exception to the "always returns a result" contract on + // executeApplicationCreate's JSDoc. It's only this test's mock that + // turns that exit into a returned result instead. + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) // missing headers → would trigger patch + + // Deliberately no PATCH or POST /api/application interceptors registered — + // if either were called, afterEach's nock.isDone() check would fail. + + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('--yes bypasses the confirmation prompt and proceeds with the CORS patch', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration') + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + + assert.equal(result.success, true) + }) + + test('a missing authorized origin alone (headers/enabled already compliant) still requires confirmation', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) // headers/enabled compliant; no allowedOrigins at all + + // Deliberately no PATCH or POST /api/application interceptors registered. + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + }) +}) + +// --------------------------------------------------------------------------- +// Output — clientSecret presence/absence +// --------------------------------------------------------------------------- + +describe('output', () => { + test('clientSecret is returned for webapp profile', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('spa profile result includes name/applicationId/clientId and omits clientSecret', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) // no clientSecret in response + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, undefined) + assert.equal(result.name, 'Test App') + assert.equal(result.applicationId, APP_ID) + assert.equal(result.clientId, APP_ID) + }) +}) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index cf7ba13..ea24a7f 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -1,9 +1,14 @@ import { describe, test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { validateEmail, validatePassword, resolveInstallAnswers, + resolveResourcesDir, } from '../../src/commands/kickstart-install.js' // --------------------------------------------------------------------------- @@ -15,12 +20,21 @@ describe('validateEmail()', () => { assert.equal(validateEmail('admin@example.com'), true) }) + test('accepts an email with subdomain', () => { + assert.equal(validateEmail('user@mail.example.co.uk'), true) + }) + test('rejects an address with no @', () => { const result = validateEmail('notanemail') assert.notEqual(result, true) assert.match(result, /valid email/) }) + test('rejects an address with no domain', () => { + const result = validateEmail('user@') + assert.notEqual(result, true) + }) + test('rejects an empty string', () => { const result = validateEmail('') assert.notEqual(result, true) @@ -36,6 +50,10 @@ describe('validatePassword()', () => { assert.equal(validatePassword('abcdefgh'), true) }) + test('accepts a long password', () => { + assert.equal(validatePassword('supersecretpassword123'), true) + }) + test('rejects an empty password', () => { const result = validatePassword('') assert.notEqual(result, true) @@ -327,3 +345,108 @@ describe('resolveInstallAnswers() — inquirer validate functions', () => { assert.equal(passwordQuestion.validate, validatePassword) }) }) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() +// --------------------------------------------------------------------------- +// +// These use synthetic temp directories (via the injectable baseDir param) +// rather than the real repo layout, so all three logic branches are +// deterministically exercised regardless of whether a build has run — +// including the dist-layout branch, which this test file could never reach +// otherwise, since it always imports src/commands/kickstart-install.js via +// tsx, fixing __dirname to .../src/commands for the whole test run. + +describe('resolveResourcesDir()', () => { + const createdDirs = [] + + function mkTempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of createdDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns baseDir/resources when it exists (dist layout)', () => { + const baseDir = mkTempDir('resolve-resources-dist-') + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('falls back to baseDir/../resources when baseDir/resources is missing (src layout)', () => { + const parent = mkTempDir('resolve-resources-src-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + const expected = path.join(parent, 'resources') + fs.mkdirSync(expected) + // Deliberately no baseDir/resources — only the parent-level fallback exists. + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('prefers the dist layout when both exist', () => { + const parent = mkTempDir('resolve-resources-both-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + fs.mkdirSync(path.join(parent, 'resources')) + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('throws a clear error when neither layout exists', () => { + const baseDir = mkTempDir('resolve-resources-none-') + + assert.throws( + () => resolveResourcesDir(baseDir), + /Could not locate kickstart resources directory/ + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() against the real built artifact (dist/) +// --------------------------------------------------------------------------- +// +// The tests above verify the function's logic in isolation; this verifies +// the actual distributable: that `npm run build`'s copy-files step really +// produces a dist/commands/resources directory the compiled command can +// find at its real __dirname, with the files kickstart:install needs. +// Skipped (not failed) when dist/ hasn't been built yet, so `test:unit` +// still works without requiring a build first — this is CI-meaningful +// since CI always runs `npm run build` before `npm test`. + +describe('resolveResourcesDir() against dist/ (built artifact)', () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const distModulePath = '../../dist/commands/kickstart-install.js' + const distModuleFile = path.join(__dirname, distModulePath) + + test('resolves dist/commands/resources from the compiled module', async (t) => { + if (!fs.existsSync(distModuleFile)) { + t.skip('dist/ has not been built — run `npm run build` first to exercise this test') + return + } + + const dist = await import(distModulePath) + const resourcesDir = dist.resolveResourcesDir() + + assert.equal(resourcesDir, path.join(path.dirname(distModuleFile), 'resources')) + assert.ok(fs.existsSync(resourcesDir), `${resourcesDir} should exist`) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'fusionauth')), + `${resourcesDir}/kickstart/fusionauth should exist` + ) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'kickstart.json')), + `${resourcesDir}/kickstart/kickstart.json should exist` + ) + }) +}) diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js new file mode 100644 index 0000000..8527a64 --- /dev/null +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -0,0 +1,263 @@ +import { describe, test, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + startFusionAuthContainer, + stopFusionAuthContainer, + getApplication, + deleteApplication, + captureSystemConfigurationBaseline, + resetSystemConfiguration, + makeApiRequest, +} from '../setup.js' +import { executeApplicationCreate } from '../../../src/commands/application-create.js' + +const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' +const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept', 'content-type'] +const REDIRECT_URI = 'https://example.com/callback' + +describe('application:create integration tests', () => { + let fusionAuthUrl + let apiKey + let systemConfigBaseline + const createdApplicationIds = [] + + before(async () => { + const container = await startFusionAuthContainer() + fusionAuthUrl = container.url + apiKey = container.apiKey + systemConfigBaseline = await captureSystemConfigurationBaseline(apiKey) + }) + + after(async () => { + await stopFusionAuthContainer() + }) + + afterEach(async () => { + // Delete any applications created during the test + for (const id of createdApplicationIds.splice(0)) { + try { await deleteApplication(id, apiKey) } catch (_) {} + } + // Restore CORS to the captured baseline + await resetSystemConfiguration(apiKey) + }) + + function baseOptions(overrides = {}) { + return { + name: `Integration Test App ${Date.now()}`, + key: apiKey, + host: fusionAuthUrl, + tenantId: TENANT_ID, + // Bypasses the CORS-change confirmation prompt (spa profile). + // The confirmation gate itself is covered by unit tests; these + // integration tests are focused on real API behavior. + yes: true, + ...overrides, + } + } + + function spaOptions(overrides = {}) { + return baseOptions({ profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides }) + } + + function webappOptions(overrides = {}) { + return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) + } + + // Verifies the required CORS headers (see REQUIRED_CORS_HEADERS) for the + // spa profile were added to system configuration, and that CORS is enabled. + async function assertCorsHeadersConfigured(apiKey) { + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = (sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? []) + .map(h => h.toLowerCase()) + for (const required of REQUIRED_CORS_HEADERS) { + assert(corsHeaders.includes(required), `CORS allowedHeaders should contain '${required}'`) + } + assert.equal(sysConfig.systemConfiguration.corsConfiguration?.enabled, true) + } + + // --------------------------------------------------------------------------- + // Happy paths — one per profile + // --------------------------------------------------------------------------- + + test('--profile spa creates application with correct settings and configures CORS', async () => { + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.applicationId, 'applicationId should be set') + assert.ok(result.clientId, 'clientId should be set') + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + // Verify application settings were persisted correctly + const app = await getApplication(result.applicationId, apiKey) + assert.ok(app, 'application should exist in FusionAuth') + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireClientAuthentication, false) + assert.equal(app.oauthConfiguration.generateRefreshTokens, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code', 'refresh_token']) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, [REDIRECT_URI]) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + + // Verify CORS headers were added to system configuration + await assertCorsHeadersConfigured(apiKey) + }) + + test('--authorized-origin-url is added to the system CORS allowlist, not just the application', async () => { + const origin = 'https://myapp.example.com' + const result = await executeApplicationCreate(spaOptions({ authorizedOriginUrl: [origin] })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + + // The application-level setting (iframe/X-Frame-Options allowlist for + // hosted pages) is a separate concern from the system CORS allowlist + // below, but --authorized-origin-url should still populate it as before. + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual(app.oauthConfiguration.authorizedOriginURLs, [origin]) + + // The system-wide CORS allowlist must also include it, or the browser + // will block the spa's actual cross-origin requests to the API despite + // CORS being "configured" (headers/enabled only, per the bug this + // guards against). + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const allowedOrigins = sysConfig.systemConfiguration.corsConfiguration?.allowedOrigins ?? [] + assert(allowedOrigins.includes(origin), `system CORS allowedOrigins should contain '${origin}'`) + }) + + test('--profile native creates application without touching system CORS configuration', async () => { + const result = await executeApplicationCreate(baseOptions({ + profile: 'native', + redirectUri: ['myapp://callback'], + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['myapp://callback']) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + + // Native apps don't go through a browser's CORS enforcement, so + // --profile native should leave system CORS configuration untouched, + // unlike spa. + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + assert.deepEqual(sysConfig.systemConfiguration.corsConfiguration, systemConfigBaseline.corsConfiguration) + }) + + test('--profile webapp creates confidential client and returns clientSecret', async () => { + const result = await executeApplicationCreate(webappOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.clientSecret, 'webapp should have a client secret') + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'NotRequiredWhenUsingClientAuthentication') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'Required') + assert.equal(app.oauthConfiguration.requireClientAuthentication, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 3600) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + }) + + test('--data custom mode creates application with provided configuration', async () => { + const customApp = { + oauthConfiguration: { + enabledGrants: ['authorization_code', 'refresh_token'], + authorizedRedirectURLs: ['https://custom.example.com/cb'], + generateRefreshTokens: true, + }, + } + + const result = await executeApplicationCreate(baseOptions({ + data: JSON.stringify(customApp), + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual( + app.oauthConfiguration.authorizedRedirectURLs, + ['https://custom.example.com/cb'] + ) + assert( + app.oauthConfiguration.enabledGrants.includes('authorization_code'), + 'should include authorization_code grant' + ) + }) + + // --------------------------------------------------------------------------- + // CORS idempotency + // --------------------------------------------------------------------------- + + test('running spa create twice does not duplicate CORS headers', async () => { + // First create + const result1 = await executeApplicationCreate(spaOptions()) + assert.equal(result1.success, true) + createdApplicationIds.push(result1.applicationId) + + // Second create without resetting CORS + const result2 = await executeApplicationCreate(spaOptions({ redirectUri: ['https://example.com/callback2'] })) + assert.equal(result2.success, true) + createdApplicationIds.push(result2.applicationId) + + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? [] + const corsHeadersLower = corsHeaders.map(h => h.toLowerCase()) + + // Each required header should appear exactly once + for (const required of REQUIRED_CORS_HEADERS) { + const count = corsHeadersLower.filter(h => h === required).length + assert.equal(count, 1, `'${required}' should appear exactly once in CORS allowedHeaders, got ${count}`) + } + }) + + // --------------------------------------------------------------------------- + // Regression: tenant header scoping (live server) + // Confirms /api/system-configuration actually accepts the request without + // the X-FusionAuth-TenantId header, which was the root cause of the 401. + // --------------------------------------------------------------------------- + + test('system-configuration is reachable without tenant header when --tenant-id is provided', async () => { + // If the tenant header were incorrectly sent to /api/system-configuration, + // this would fail with 401 — exactly the bug we fixed. + // (--tenant-id is already the default in baseOptions()/spaOptions().) + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + }) + + // --------------------------------------------------------------------------- + // --application-id override + // --------------------------------------------------------------------------- + + test('--application-id is respected and application is created with that ID', async () => { + const customId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const result = await executeApplicationCreate(webappOptions({ applicationId: customId })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.equal(result.applicationId, customId) + + createdApplicationIds.push(customId) + + const app = await getApplication(customId, apiKey) + assert.ok(app, 'application should exist with the specified ID') + assert.equal(app.id, customId) + }) +}) 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 index 2314490..7b96011 100644 --- a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml @@ -12,7 +12,6 @@ services: retries: 5 networks: - db_net - restart: unless-stopped volumes: - db_data:/var/lib/postgresql/data @@ -29,7 +28,6 @@ services: 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 @@ -68,7 +66,6 @@ services: networks: - db_net - search_net - restart: unless-stopped ports: - 9011:9011 volumes: diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index f27ae2e..7059e6b 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -2,6 +2,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' /** * Async version of exec used in place of execSync to avoid blocking the @@ -16,13 +17,138 @@ const execAsync = promisify(exec) * Handles docker compose lifecycle and FusionAuth readiness checks */ -const FUSIONAUTH_URL = 'http://localhost:9011' +const DEFAULT_FUSIONAUTH_URL = 'http://localhost:9011' const DEFAULT_API_KEY = '90dd6b25-d1ef-4175-9656-159dd994932e' -const HEALTH_CHECK_TIMEOUT = 120000 // 2 minutes +const HEALTH_CHECK_TIMEOUT = 240000 // 4 minutes const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const REQUEST_TIMEOUT = 10000 // 10 seconds +// fileURLToPath() (not .pathname) is required here: .pathname leaves +// characters like spaces percent-encoded (e.g. '%20'), which is not a +// valid path component on disk and would break both writeFileSync(envFile) +// and every docker compose invocation below for a checkout under a path +// containing a space. +const COMPOSE_DIR = fileURLToPath(new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url)) let isContainerRunning = false +let resolvedFusionAuthUrl = DEFAULT_FUSIONAUTH_URL + +/** + * Best-effort teardown used by the SIGINT/SIGTERM handlers below. Unlike + * stopFusionAuthContainer(), this does not check isContainerRunning — a + * termination signal can arrive before that flag is set (e.g. while still + * waiting on docker compose up -d or the health check loop), by which point + * containers may already exist and still need cleaning up. + * @param {string} reason - what triggered the teardown, for logging + */ +async function forceTeardown(reason) { + if (process.env.SKIP_TEARDOWN === 'true') { + console.log(`ℹ Skipping container teardown on ${reason} (SKIP_TEARDOWN=true)`) + return + } + try { + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + isContainerRunning = false + } catch (err) { + console.error(`Warning: Failed to stop container during ${reason} cleanup: ${err.message}`) + } +} + +let handlingTerminationSignal = false + +/** + * Ensures a Ctrl+C (or kill) during a test run doesn't leak the FusionAuth + * container — without this, after()/t.after() hooks never run on an + * interrupted process, leaving containers running (or stopped-but-not- + * removed, which can then collide with the next run's `docker compose up`). + * @param {string} signal + */ +async function handleTerminationSignal(signal) { + if (handlingTerminationSignal) { + // Second signal while teardown is still in flight (e.g. docker compose + // down -v hung) — the user wants out now. Exit immediately rather than + // silently no-op'ing: once a SIGINT/SIGTERM listener is registered, + // Node no longer applies its default "second Ctrl+C just kills the + // process" behavior on its own, so we have to implement that ourselves. + console.log(`\n⚠ Received ${signal} again — forcing immediate exit (teardown may be incomplete).`) + process.exit(signal === 'SIGINT' ? 130 : 143) + } + handlingTerminationSignal = true + console.log(`\n⚠ Received ${signal}, cleaning up FusionAuth container before exiting...`) + await forceTeardown(signal) + process.exit(signal === 'SIGINT' ? 130 : 143) +} + +process.on('SIGINT', () => { void handleTerminationSignal('SIGINT') }) +process.on('SIGTERM', () => { void handleTerminationSignal('SIGTERM') }) + +/** + * Resolves the FusionAuth service container's ID via Docker Compose, + * rather than assuming Compose's default generated container name + * ('{project}-{service}-{index}'). That default only holds when + * COMPOSE_PROJECT_NAME is unset; if it's set (e.g. by a contributor's + * shell profile or CI wrapper), the real container name differs and a + * hard-coded guess would silently fail to match anything. + * @returns {Promise} The container ID, or '' if it can't be found + * (e.g. the compose project doesn't exist yet) — callers should treat + * that the same as a failed `docker inspect` and fall back gracefully. + */ +async function resolveContainerId() { + try { + const { stdout } = await execAsync('docker compose --env-file .env.test ps -q fusionauth', { cwd: COMPOSE_DIR }) + return stdout.trim() + } catch (_) { + return '' + } +} + +/** + * Resolves the FusionAuth URL. On environments where localhost port-mapping + * behaves differently (e.g. macOS Docker Desktop), falls back to the + * container's direct bridge IP to ensure authenticated requests succeed. + * @returns {Promise} + */ +async function resolveFusionAuthUrl() { + // First try localhost — if an authenticated request succeeds, use it. + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`${DEFAULT_FUSIONAUTH_URL}/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) return DEFAULT_FUSIONAUTH_URL + } catch (_) {} + + // Fall back to the container's direct bridge IP (works on macOS Docker Desktop + // where localhost port-mapping doesn't forward API-key auth correctly). + try { + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ips = stdout.trim().split(/\s+/).filter(Boolean) + for (const ip of ips) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`http://${ip}:9011/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) { + console.log(`ℹ Using container IP ${ip}:9011 (localhost port-mapping not compatible)`) + return `http://${ip}:9011` + } + } catch (_) {} + } + } catch (_) {} + + // Return localhost as a last resort — health check will catch startup failures. + return DEFAULT_FUSIONAUTH_URL +} /** * Start FusionAuth via docker compose @@ -31,14 +157,14 @@ let isContainerRunning = false 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 } + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + return { url: resolvedFusionAuthUrl, 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') + const envFile = path.join(COMPOSE_DIR, '.env.test') + const kickstartFilePath = path.join(COMPOSE_DIR, 'kickstart.json') // Create .env.test file with test configuration const envContent = ` @@ -57,28 +183,44 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m fs.writeFileSync(envFile, envContent) try { - // Check for and tear down any existing containers first + // Check for and tear down any existing containers first. Use -a/--all — + // without it, docker compose ps only lists running/restarting + // containers, so a stopped-but-not-removed container from a prior + // interrupted run would be invisible here, this cleanup would be + // skipped entirely, and the `up -d` below would fail with + // "Conflict: container name already in use". + let psOutput = '' try { - const { stdout: psOutput } = await execAsync(`cd ${composeDir} && docker compose ps -q`) - if (psOutput.trim()) { - console.log('⚠ Found existing FusionAuth containers, tearing them down...') - await execAsync(`cd ${composeDir} && docker compose down -v`) - console.log('✓ Existing containers removed') - } + const result = await execAsync('docker compose --env-file .env.test ps -aq', { cwd: COMPOSE_DIR }) + psOutput = result.stdout } catch (e) { - // Container may not exist, that's fine + // `docker compose ps` itself failing (e.g. project has never existed) + // is fine — there's nothing to tear down. + } + + if (psOutput.trim()) { + console.log('⚠ Found existing FusionAuth containers, tearing them down...') + // Unlike the ps check above, a failure here means stale containers + // genuinely remain. Let it propagate (via the outer catch) instead of + // silently continuing into `up -d`, which would just hit the same + // naming conflict with a far more confusing error message. + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + console.log('✓ Existing containers removed') } // Start containers - await execAsync(`cd ${composeDir} && docker compose --env-file .env.test up -d`) + await execAsync('docker compose --env-file .env.test up -d', { cwd: COMPOSE_DIR }) // Wait for FusionAuth to be healthy await waitForFusionAuthReady() + // Resolve the URL that actually works for authenticated requests + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + isContainerRunning = true console.log('✓ FusionAuth container started and ready') - return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + return { url: resolvedFusionAuthUrl, apiKey: DEFAULT_API_KEY } } catch (err) { throw new Error(`Failed to start FusionAuth container: ${err.message}`) } @@ -101,10 +243,8 @@ export async function stopFusionAuthContainer() { console.log('↻ Stopping FusionAuth container...') - const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname - try { - await execAsync(`cd ${composeDir} && docker compose down -v`) + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { @@ -124,36 +264,61 @@ async function waitForFusionAuthReady() { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - const response = await fetch(`${FUSIONAUTH_URL}/api/status`, { + const response = await fetch(`${DEFAULT_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 + // Verify the kickstart has run and the container is fully initialized + // by checking authenticated API access. Tries localhost first, then + // falls back to the container's direct bridge IP. let authReady = false const authStartTime = Date.now() while (Date.now() - authStartTime < 30000) { // 30 second timeout for auth readiness + // Try localhost first, only falling back to the container's direct + // bridge IP (via docker inspect) if localhost doesn't respond ok. + // Mirrors resolveFusionAuthUrl()'s ordering — on Docker Desktop the + // bridge IP generally isn't routable from the host, so localhost + // must be attempted first rather than being unconditionally + // overridden. + const urlsToTry = [DEFAULT_FUSIONAUTH_URL] 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 + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ip = stdout.trim().split(/\s+/).filter(Boolean)[0] + if (ip) urlsToTry.push(`http://${ip}:9011`) + } catch (_) {} + + for (const checkUrl of urlsToTry) { + try { + const authController = new AbortController() + const authTimeoutId = setTimeout(() => authController.abort(), 5000) + + const tenantsResponse = await fetch(`${checkUrl}/api/tenant`, { + method: 'GET', + headers: { Authorization: DEFAULT_API_KEY }, + signal: authController.signal + }) + clearTimeout(authTimeoutId) + + if (tenantsResponse.ok) { + authReady = true + break + } + } catch (err) { + // Not reachable via this URL yet, try the next one } - } catch (err) { - // Auth not ready yet, retry } - + + if (authReady) { + break + } + await sleep(HEALTH_CHECK_INTERVAL) } @@ -182,7 +347,7 @@ async function waitForFusionAuthReady() { * @returns {Promise} */ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT_API_KEY) { - const url = `${FUSIONAUTH_URL}${path}` + const url = `${resolvedFusionAuthUrl}${path}` const headers = { Authorization: apiKey, 'Content-Type': 'application/json' @@ -212,7 +377,15 @@ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT ) } - return await response.json() + // Some successful responses (e.g. DELETE /api/application) have an + // empty body — calling response.json() directly throws in that case + // ("Unexpected end of JSON input"), so read as text first and only + // parse when there's actually something to parse. + const responseText = await response.text() + if (!responseText) { + return null + } + return JSON.parse(responseText) } catch (err) { if (err.name === 'AbortError') { throw new Error(`API request timeout: ${method} ${path}`) @@ -269,6 +442,57 @@ export async function getMessageTemplateByName(name, apiKey = DEFAULT_API_KEY) { return templates.find(t => t.name === name) } +/** + * Get application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getApplication(applicationId, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', `/api/application/${applicationId}`, null, apiKey) + return data.application +} + +/** + * Delete application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function deleteApplication(applicationId, apiKey = DEFAULT_API_KEY) { + // First deactivate, then hard-delete + await makeApiRequest('DELETE', `/api/application/${applicationId}`, null, apiKey) + await makeApiRequest('DELETE', `/api/application/${applicationId}?hardDelete=true`, null, apiKey) +} + +let baselineSystemConfiguration = null + +/** + * Captures the current system configuration as the baseline to restore to + * after CORS-mutating tests. Must be called once before any test that + * modifies system configuration (e.g. application:create --profile spa). + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function captureSystemConfigurationBaseline(apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + baselineSystemConfiguration = data.systemConfiguration + return baselineSystemConfiguration +} + +/** + * Restores system configuration to the captured baseline. Uses PUT (full + * overwrite) rather than PATCH so the restore is exact, not merged. + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function resetSystemConfiguration(apiKey = DEFAULT_API_KEY) { + if (!baselineSystemConfiguration) { + throw new Error('captureSystemConfigurationBaseline() must be called before resetSystemConfiguration()') + } + await makeApiRequest('PUT', '/api/system-configuration', { systemConfiguration: baselineSystemConfiguration }, apiKey) +} + /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep diff --git a/__tests__/telemetry/telemetry.test.js b/__tests__/telemetry/telemetry.test.js index 8de09df..798c9b8 100644 --- a/__tests__/telemetry/telemetry.test.js +++ b/__tests__/telemetry/telemetry.test.js @@ -99,6 +99,14 @@ describe('tests for logEvent', () => { beforeEach(() => { tempDir = createTempDir() process.env.FUSIONAUTH_CONFIG_DIR = tempDir + // This block's tests assert on the presence/absence of + // FUSIONAUTH_TELEMETRY, including a test that requires it to be + // completely unset. Running the suite with FUSIONAUTH_TELEMETRY=false + // (as npm run test:unit does, to keep logEvent() from making real + // network/filesystem calls in *other* test files) would otherwise + // leak into these tests depending on execution order. Start every + // test here from a known, unset baseline instead of relying on that. + delete process.env.FUSIONAUTH_TELEMETRY }) afterEach(() => { diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 2f2b097..ecb754c 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -1,6 +1,6 @@ import { describe, test } from "node:test" import assert from "node:assert/strict" -import { isConfirmationAccepted, handleConfirmationAnswer, confirmOrExit } from "../src/utils.js" +import { isConfirmationAccepted, handleConfirmationAnswer, confirmOrExit, reportError } from "../src/utils.js" describe('isConfirmationAccepted()', () => { test('accepts "y"', () => { @@ -137,3 +137,22 @@ describe('confirmOrExit()', () => { assert.equal(exitMock.mock.calls[0].arguments[0], 1) }) }) + +// --------------------------------------------------------------------------- +// reportError() — unknown-shape fallback +// --------------------------------------------------------------------------- + +describe('reportError() fallback for unknown error shapes', () => { + test('does not throw and still prints something useful for a circular-reference object', (t) => { + // JSON.stringify (the old implementation, toJson) throws on circular + // references. util.inspect handles this gracefully instead. + const errorMock = t.mock.method(console, 'error', () => {}) + const circular = { statusCode: 500 } + circular.self = circular + + assert.doesNotThrow(() => reportError('Something went wrong', circular)) + + const printedLines = errorMock.mock.calls.map((call) => call.arguments[0]) + assert.ok(printedLines.some((line) => line.includes('statusCode')), 'should print the object contents') + }) +}) diff --git a/package-lock.json b/package-lock.json index fd8ea8c..b0cbbbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 77ba0b0..4b3dbad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "description": "FusionAuth CLI", "main": "dist/index.js", "engines": { @@ -16,7 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "npm run build && node dist/index.js", - "test": "node --import tsx --test", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test --test-concurrency=1 '__tests__/integration/**/*.test.js'", + "test:unit": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test __tests__/utils.test.js '__tests__/postInstall/*.test.js' '__tests__/telemetry/*.test.js' '__tests__/utilities/**/*.test.js' '__tests__/commands/*.test.js'", "prepare": "husky" }, "keywords": [ diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts new file mode 100644 index 0000000..d5afbd4 --- /dev/null +++ b/src/commands/application-create.ts @@ -0,0 +1,502 @@ +import * as fs from 'node:fs'; +import {Command, Option} from '@commander-js/extra-typings'; +import boxen from 'boxen'; +import { + Application, + ClientAuthenticationPolicy, + CORSConfiguration, + FusionAuthClient, + GrantType, + ProofKeyForCodeExchangePolicy, + RefreshTokenExpirationPolicy, + RefreshTokenUsagePolicy, +} from '@fusionauth/typescript-client'; +import chalk from 'chalk'; +import {confirmOrExit, logEvent} from '../utils.js'; +import {apiKeyOption, hostOption} from '../options.js'; +import * as utils from '../utils.js'; + +type Profile = 'spa' | 'native' | 'webapp'; + +export interface ApplicationCreateOptions { + name?: string; + profile?: string; + redirectUri?: string[]; + logoutUrl?: string; + authorizedOriginUrl?: string[]; + applicationId?: string; + tenantId?: string; + data?: string; + yes?: boolean; + key: string; + host: string; +} + +export interface ApplicationCreateResult { + success: boolean; + error?: string; + rawError?: unknown; + applicationId?: string; + clientId?: string; + clientSecret?: string; + name?: string; +} + +// Shared refresh token policy (usage + expiration) across all profiles. A +// sliding window of one-time-use refresh tokens is the recommended default +// for spa/native/webapp. This does not include timeToLiveInSeconds — that's +// the access token (JWT) lifetime, set separately per profile below in +// jwtConfiguration, not part of the refresh token policy itself. +const defaultRefreshTokenPolicy = { + refreshTokenUsagePolicy: RefreshTokenUsagePolicy.OneTimeUse, + refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, +}; + +// requireRegistration: true means a user must have a registration for this +// application before they can complete the authorization_code/implicit grant. +// registrationConfiguration.enabled is intentionally left false (self-service +// registration is off), so registrations must be created out-of-band — e.g. +// via the Registration API — before a user can log in. See the "Create users" +// Next Steps link printed after a successful create. +function buildPublicClientDefaults(): Application { + return { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.Required, + clientAuthenticationPolicy: ClientAuthenticationPolicy.NotRequired, + requireClientAuthentication: false, + requireRegistration: true, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 300, + ...defaultRefreshTokenPolicy, + }, + }; +} + +const profileDefaults: Record = { + spa: buildPublicClientDefaults(), + native: buildPublicClientDefaults(), + webapp: { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.NotRequiredWhenUsingClientAuthentication, + clientAuthenticationPolicy: ClientAuthenticationPolicy.Required, + requireClientAuthentication: true, + // See comment on buildPublicClientDefaults() above re: requireRegistration. + requireRegistration: true, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 3600, + ...defaultRefreshTokenPolicy, + }, + }, +}; + +// Headers a spa app needs the browser to allow through CORS: 'dpop' and +// 'Authorization' for DPoP-bound bearer tokens, and 'Accept'/'Content-Type' +// because JSON request/response bodies are not CORS-safelisted by default +// (unlike e.g. application/x-www-form-urlencoded) — without 'Content-Type' +// here, a SPA sending `Content-Type: application/json` would still fail +// preflight even after this command reports CORS as configured. +const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept', 'Content-Type']; + +/** + * Wraps an unknown error with additional context while preserving the + * original value (e.g. a FusionAuth `ClientResponse` rejection, which + * carries structured `fieldErrors`/`generalErrors`) as `.cause`, so callers + * further up the stack can still access it for rich reporting instead of + * only the flattened message string. + */ +function wrapError(message: string, cause: unknown): Error { + const error = new Error(message); + (error as Error & {cause?: unknown}).cause = cause; + return error; +} + +/** + * Unwraps an error produced by wrapError() back to its original cause, for + * use as ApplicationCreateResult.rawError — which exists specifically to + * carry structured detail (e.g. FusionAuth's fieldErrors/generalErrors) + * beyond the plain message already captured in ApplicationCreateResult.error. + * + * Returns undefined for a direct, never-wrapped Error (e.g. parseData()'s + * validation errors) — its message is already the `error` string, so + * returning the same Error object again as rawError would make + * errorAndExit()/reportError() print that message a second time. Only a + * genuinely-wrapped error's distinct .cause, or a rejection that was never + * an Error at all (e.g. a raw ClientResponse-shaped object thrown without + * wrapError()), is preserved — both can carry detail worth reporting. + */ +function unwrapError(e: unknown): unknown { + if (e instanceof Error) { + return 'cause' in e && e.cause !== undefined ? e.cause : undefined; + } + return e; +} + +/** + * Ensures that the required CORS headers (see REQUIRED_CORS_HEADERS) — and, + * when authorizedOrigins is non-empty, those origins — are present in the + * FusionAuth system configuration. Also enables CORS if it is currently + * disabled. Should be called for the spa profile before creating the + * application. + * + * Enabling CORS and allowing the right headers is not sufficient on its + * own: FusionAuth's CORS allowlist (corsConfiguration.allowedOrigins) is a + * separate, independent setting, and browsers will still block cross-origin + * requests from the spa app's own origin unless it's present there (or + * allowedOrigins is "*"). --authorized-origin-url is the only source of + * that origin available to this command, so it's reused here in addition + * to populating application.oauthConfiguration.authorizedOriginURLs. + * + * CORS only applies to browser-based requests, so this is only relevant + * for the spa profile — native apps don't go through a browser's CORS + * enforcement at all, so this should not be called for native. If this + * call fails the entire command is aborted — no application will be + * created. + * + * This mutates system-wide configuration, so it is gated behind + * confirmOrExit()/--yes and only prompts when a change is actually needed. + * + * Note: /api/system-configuration does not accept a tenant ID. The tenant + * header is cleared for these calls and restored afterward. + */ +async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean, authorizedOrigins: string[]): Promise { + const originalTenantId = client.tenantId ?? null; + client.setTenantId(null); + + try { + let retrieveResponse; + try { + retrieveResponse = await client.retrieveSystemConfiguration(); + } catch (e: unknown) { + throw wrapError('Error retrieving system configuration', e); + } + + const systemConfig = retrieveResponse.response.systemConfiguration!; + const cors: CORSConfiguration = systemConfig.corsConfiguration ?? {}; + const existing: string[] = cors.allowedHeaders ?? []; + const existingLower = existing.map((h) => h.toLowerCase()); + + const missing = REQUIRED_CORS_HEADERS.filter( + (h) => !existingLower.includes(h.toLowerCase()) + ); + + // Origins are case-sensitive, unlike header names, and "*" already + // permits every origin — nothing to add in that case. Dedupe the + // supplied origins first — authorizedOrigins is only ever compared + // against the pre-existing allowlist below, so a duplicate within + // authorizedOrigins itself (e.g. --authorized-origin-url passed the + // same URL twice) would otherwise pass that filter twice and write + // a duplicate entry into the system-wide CORS configuration. + const existingOrigins: string[] = cors.allowedOrigins ?? []; + const dedupedAuthorizedOrigins = [...new Set(authorizedOrigins)]; + const missingOrigins = existingOrigins.includes('*') + ? [] + : dedupedAuthorizedOrigins.filter((o) => !existingOrigins.includes(o)); + + const needsEnable = cors.enabled !== true; + + if (missing.length === 0 && missingOrigins.length === 0 && !needsEnable) { + return; + } + + const changes = [ + ...(needsEnable ? ['enable CORS'] : []), + ...(missing.length > 0 ? [`add CORS header(s): ${missing.join(', ')}`] : []), + ...(missingOrigins.length > 0 ? [`add CORS allowed origin(s): ${missingOrigins.join(', ')}`] : []), + ].join(' and '); + + await confirmOrExit( + `This will modify your FusionAuth system configuration to ${changes}. ` + + 'This may allow cross-domain requests that were previously blocked.', + yes + ); + + try { + await client.patchSystemConfiguration({ + systemConfiguration: { + corsConfiguration: { + ...cors, + enabled: true, + allowedHeaders: [...existing, ...missing], + ...(missingOrigins.length > 0 + ? {allowedOrigins: [...existingOrigins, ...missingOrigins]} + : {}), + }, + }, + }); + + if (missing.length > 0) { + console.log(` CORS headers added: ${missing.join(', ')}`); + } + if (missingOrigins.length > 0) { + console.log(` CORS allowed origins added: ${missingOrigins.join(', ')}`); + } + } catch (e: unknown) { + throw wrapError('Error updating CORS configuration', e); + } + } finally { + client.setTenantId(originalTenantId); + } +} + +/** + * Parses the --data value. If it begins with '@', reads the referenced file. + * Otherwise parses the value as inline JSON. + * Throws an Error on parse, file-read, or shape failure (caught by + * executeApplicationCreate). "Shape failure" means the parsed JSON is valid + * but isn't a non-null, non-array object — e.g. `--data 'null'` or + * `--data '[1,2,3]'` would otherwise be cast to Application unchecked, + * surfacing as a confusing downstream TypeError (null) or a nonsensical + * API payload (array/primitive) instead of a clear validation error here. + */ +function parseData(data: string): Application { + let json: string; + if (data.startsWith('@')) { + const filePath = data.slice(1); + try { + json = fs.readFileSync(filePath, 'utf-8'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error reading --data file "${filePath}": ${message}`); + } + } else { + json = data; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error parsing --data JSON: ${message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `--data JSON must be a non-null, non-array object, got ${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.` + ); + } + return parsed as Application; +} + +/** + * Core logic for application:create. Returns a result object on every + * validation/API failure it detects itself, rather than calling + * process.exit() directly — this is what allows tests to import and invoke + * it, and non-CLI callers to handle failures programmatically. + * + * One exception: for the spa profile, this calls ensureCorsHeaders(), + * which calls confirmOrExit() to gate a system-wide CORS mutation per this + * project's Risky Operations convention (see kickstart-kill.ts for the + * same pattern elsewhere). confirmOrExit() does call process.exit() for a + * non-interactive caller without yes=true, or an interactive caller who + * declines — so a direct (non-CLI) caller in that situation will still see + * the process terminate rather than a returned result. Pass yes: true to + * avoid this when calling programmatically in a non-interactive context. + */ +export async function executeApplicationCreate(options: ApplicationCreateOptions): Promise { + const { + name, + profile, + redirectUri, + logoutUrl, + authorizedOriginUrl, + applicationId, + tenantId, + data, + yes, + key: apiKey, + host, + } = options; + + try { + await logEvent('cli command application:create'); + + // --- Mode validation --- + if (profile && data) { + return { success: false, error: '--profile and --data are mutually exclusive. Provide one or the other.' }; + } + if (!profile && !data) { + return { success: false, error: 'Either --profile or --data is required.' }; + } + + let application: Application; + + if (profile) { + // --- Profile mode --- + if (redirectUri === undefined || redirectUri.length === 0) { + return { success: false, error: '--redirect-uri is required when using --profile.' }; + } + if (!name) { + return { success: false, error: '--name is required when using --profile.' }; + } + if (!Object.keys(profileDefaults).includes(profile)) { + return { success: false, error: `--profile must be one of: ${Object.keys(profileDefaults).join(', ')}.` }; + } + + const defaults = profileDefaults[profile as Profile]; + application = {...defaults}; + application.name = name; + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedRedirectURLs: redirectUri, + ...(logoutUrl ? {logoutURL: logoutUrl} : {}), + ...(authorizedOriginUrl && authorizedOriginUrl.length > 0 + ? {authorizedOriginURLs: authorizedOriginUrl} + : {}), + }; + } else { + // --- Custom mode --- + // --data provides "full custom control": the JSON is the source of + // truth, and --name/--redirect-uri/--logout-url/--authorized-origin-url + // are all optional overrides that only take effect if explicitly + // passed, leaving the JSON's own values untouched otherwise. This + // mirrors the --application-id/--tenant-id override pattern below, + // which applies unconditionally in both modes. + application = parseData(data!); + if (name) { + application.name = name; + } + if (redirectUri && redirectUri.length > 0) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedRedirectURLs: redirectUri, + }; + } + if (logoutUrl) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + logoutURL: logoutUrl, + }; + } + if (authorizedOriginUrl && authorizedOriginUrl.length > 0) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedOriginURLs: authorizedOriginUrl, + }; + } + // Note: unlike --profile mode, this does not call ensureCorsHeaders() + // — --data mode never mutates system-wide CORS configuration, since + // "full custom control" means the caller owns their own + // infrastructure config, not just the application body. + } + + // --- ID overrides (applied last in both modes) --- + if (applicationId) { + application.id = applicationId; + } + if (tenantId) { + application.tenantId = tenantId; + } + + const fusionAuthClient = new FusionAuthClient(apiKey, host, tenantId); + + // For the spa profile, enforce DPoP-required CORS headers (and + // allowed origins, when provided) first. If this fails (or the user + // declines the confirmation prompt), the command aborts — + // createApplication is never called. Native apps don't go through + // a browser's CORS enforcement, so this is intentionally skipped + // for --profile native. + if (profile === 'spa') { + await ensureCorsHeaders(fusionAuthClient, yes ?? false, authorizedOriginUrl ?? []); + } + + let clientResponse; + try { + clientResponse = await fusionAuthClient.createApplication( + application.id ?? '', + {application} + ); + } catch (e: unknown) { + throw wrapError('Error creating application', e); + } + + const created = clientResponse.response.application!; + // clientId intentionally mirrors applicationId here: FusionAuth does not + // allow oauthConfiguration.clientId to be set via the API (it's only + // ever returned, never accepted as input), so for applications created + // by this command the two values are always identical. + const clientId = created.oauthConfiguration?.clientId ?? created.id ?? ''; + const clientSecret = created.oauthConfiguration?.clientSecret; + + return { + success: true, + applicationId: created.id, + clientId, + clientSecret, + name: created.name, + }; + + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { success: false, error: message, rawError: unwrapError(e) }; + } +} + +/** + * CLI action wrapper — calls executeApplicationCreate and handles output/exit. + */ +const action = async function (options: ApplicationCreateOptions) { + utils.betaWarning(); + + const result = await executeApplicationCreate(options); + + if (!result.success) { + utils.errorAndExit(result.error ?? 'Error creating application.', result.rawError); + return; + } + + console.log(); + console.log(chalk.green('Application created.')); + console.log(` Name: ${result.name}`); + console.log(` Application ID / client_id: ${result.clientId}`); + if (result.clientSecret) { + console.log(` Client Secret: ${result.clientSecret}`); + } + + console.log(); + console.log(boxen( + [ + `Customize FusionAuth with a ${chalk.cyan('simple theme')}:`, + ' https://fusionauth.io/docs/customize/look-and-feel/simple-theme-editor', + '', + `Create ${chalk.cyan('users')}:`, + ' https://fusionauth.io/docs/lifecycle/register-users/', + '', + `Configure an ${chalk.cyan('SMTP server')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/configure-email', + '', + `Set up ${chalk.cyan('email templates')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/email-templates', + '', + `${chalk.cyan('Add login')} to your application:`, + ' https://fusionauth.io/docs/get-started/start-here/step-1', + '', + ].join('\n'), + {padding: 1, title: 'Next Steps', borderColor: 'green', borderStyle: 'bold'} + )); +}; + +// noinspection JSUnusedGlobalSymbols +export const applicationCreate = new Command('application:create') + .description('Create an application in FusionAuth') + .option('--name ', 'The name of the application (required with --profile; overrides the name in --data if provided)') + .addOption( + new Option('--profile ', 'Security profile to apply (mutually exclusive with --data)') + .choices(['spa', 'native', 'webapp'] as const) + ) + .option('--redirect-uri ', 'Authorized redirect URIs (required with --profile)') + .option('--logout-url ', 'Post-logout redirect URL') + .option('--authorized-origin-url ', 'Authorized origin URLs for the application; also added to the system CORS allowlist for --profile spa') + .option('--data ', 'Full application config as inline JSON or @file.json (mutually exclusive with --profile)') + .option('--application-id ', 'Application UUID (auto-generated if omitted; overrides --data)') + .option('--tenant-id ', 'Tenant UUID (overrides --data)') + .option('--yes', 'Skip confirmation prompt for automatic CORS configuration changes (spa profile)', false) + .addOption(apiKeyOption) + .addOption(hostOption) + .action(action); diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 75de421..6c741d7 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -27,7 +27,7 @@ function warnDeprecatedFlags(argv: string[] = process.argv): void { for (const [old, replacement] of getDeprecatedFlagUsage(argv)) { console.warn(chalk.yellow( `DEPRECATION WARNING: please use ${replacement} going forward. ` + - `${old} will be deprecated in a future release.` + `${old} will be removed in a future release.` )); } } diff --git a/src/commands/index.ts b/src/commands/index.ts index 07f0f21..94714ef 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from './application-create.js'; export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 52aaf8c..62004b2 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -14,6 +14,38 @@ import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } fr const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * Resolves the directory containing the kickstart resource files + * (fusionauth-config files, kickstart.json, etc.), supporting both layouts + * this file can run from: + * - Built (dist/): resources live beside the compiled command, at + * dist/commands/resources, via the build's copy-files step. + * - Source (src/, e.g. running this file directly via tsx during local + * development, independent of the npm start script): resources live one + * level up, at src/resources — they are not copied anywhere until a + * build runs. + * Throws if neither layout is found, rather than silently proceeding with + * a path that doesn't exist. + * + * @param baseDir Directory to resolve relative to. Defaults to this + * module's own directory (dist/commands or src/commands, depending on + * which was imported); overridable so tests can exercise all three + * outcomes (dist found / src fallback found / neither found) against + * controlled, synthetic directories instead of depending on the real + * repo's build state. + */ +export function resolveResourcesDir(baseDir: string = __dirname): string { + const distLayout = path.join(baseDir, 'resources'); + if (fs.existsSync(distLayout)) { + return distLayout; + } + const srcLayout = path.join(baseDir, '..', 'resources'); + if (fs.existsSync(srcLayout)) { + return srcLayout; + } + throw new Error(`Could not locate kickstart resources directory (checked ${distLayout} and ${srcLayout}).`); +} + // --------------------------------------------------------------------------- // Validation helpers (exported for testing) // --------------------------------------------------------------------------- @@ -206,25 +238,34 @@ const action = async function (dir: string, options: InstallOptions) { const spinner = yoctoSpinner({ text: "Building..." }).start() - // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: - // - exceptions propagate through the surrounding try/catch - // - step ordering is deterministic regardless of machine speed - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - - const postgresPass = randomUUID() - const dbPass = randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - - spinner.success("Done building!\n") - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + try { + // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: + // - exceptions propagate through the surrounding try/catch + // - step ordering is deterministic regardless of machine speed + const resourcesDir = resolveResourcesDir(); + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${resourcesDir}/kickstart/fusionauth`, directory, { recursive: true }) + + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exist.`)) + await createKickstart(resourcesDir + '/kickstart/kickstart.json', answers, directory) + + const postgresPass = randomUUID() + const dbPass = randomUUID() + + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + } catch (e) { + // Ensure the spinner's animation interval is stopped on every failure + // path — otherwise it keeps rendering (and can keep the process alive) + // even though the outer catch below has already taken over reporting. + spinner.error("Build failed.") + throw e + } } catch (e) { console.error(e) diff --git a/src/index.ts b/src/index.ts index 7b0210f..54096a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ const authString = figlet.textSync('Auth').split('\n'); fusionString.forEach((line, i) => { console.log(chalk.white(line) + chalk.hex('#F58320')(authString[i])); }); + const program = new Command(); program.name('@fusionauth/cli').description('CLI for FusionAuth'); Object.values(commands).forEach((command) => { diff --git a/src/utils.ts b/src/utils.ts index c8358f0..acb5a9a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import fs, { readFileSync } from 'node:fs' import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; +import { inspect } from 'node:util'; import chalk from 'chalk'; import boxen from 'boxen'; @@ -29,7 +30,7 @@ export const __dirname = dirname(fileURLToPath(import.meta.url)); * @param response */ export const isClientResponse = (response: any): response is ClientResponse.default => { - return response.wasSuccessful !== undefined; + return response != null && response.wasSuccessful !== undefined; } /** @@ -37,7 +38,7 @@ export const isClientResponse = (response: any): response is ClientResponse.defa * @param response */ export const isErrors = (response: any): response is Errors => { - return response.fieldErrors !== undefined || response.generalErrors !== undefined; + return response != null && (response.fieldErrors !== undefined || response.generalErrors !== undefined); } /** @@ -85,7 +86,14 @@ export const reportError = (msg: string, error?: any): void => { return; } - console.error(chalk.red(toJson(error))); + // Last resort for a shape that matched none of the above (e.g. a plain + // object with no message/fieldErrors/generalErrors). util.inspect, not + // JSON.stringify (toJson), is used here deliberately: it handles + // circular references and non-JSON-serializable values (functions, + // undefined, symbols) gracefully instead of throwing or silently + // dropping them, which matters since `error` here is of truly unknown + // shape by this point. + console.error(chalk.red(inspect(error, { depth: null }))); } /** From 69f133a49db456b1f59ebec04c0c8fd74ba63745 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 08:27:08 -0400 Subject: [PATCH 19/33] Fixes readme typo Co-authored-by: Andy Pai <8798244+andrewpai@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0dda0c6..9ea2925 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Currently, the CLI supports the following commands: - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update - `-p, --prop ` - Update a single property in the application - - `--redirect-url ` - Update the Authorized redirect URL for your applicatoin + - `--redirect-url ` - Update the Authorized redirect URL for your application - `--example` - Create an example file with editable properties to use in conjunction with the `--data` flag - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. From 1c901381f4c9d92e61d1b270eab65225e7f87346 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 08:27:47 -0400 Subject: [PATCH 20/33] Help text change on :get to "retrieve" instead of "update" Co-authored-by: Andy Pai <8798244+andrewpai@users.noreply.github.com> --- src/commands/application/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index e814921..96ee28b 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -42,7 +42,7 @@ const action = async function (id:string, options: Record): Promise export const appGet = new Command() .command('application:get') - .argument('', "The FusionAuth Application ID to update") + .argument('', "The FusionAuth Application ID to retrieve") .option('-o, --output ', "Path where the data should be stored") .addOption(hostOption) .addOption(apiKeyOption) From 5af44209692a8829840fc8ac47b1f395712e0fdd Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 08:28:26 -0400 Subject: [PATCH 21/33] Help text change on :get to consistently match the get instead of update Co-authored-by: Andy Pai <8798244+andrewpai@users.noreply.github.com> --- src/commands/application/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 96ee28b..993ddf7 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -46,6 +46,6 @@ export const appGet = new Command() .option('-o, --output ', "Path where the data should be stored") .addOption(hostOption) .addOption(apiKeyOption) - .description('Updates an application with data provided via a file, a property, or a command flag.') + .description('Retrieves an application by id, writing its data to a local file') .action(action) \ No newline at end of file From 0bdd96c8307378e8e0058bf6994ff217167b0246 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 08:44:29 -0400 Subject: [PATCH 22/33] fix(update): removes old options from update command --- src/commands/application/update.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index c350480..c254d02 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -111,9 +111,7 @@ export const appUpdate = new Command() .command('application:update') .argument('', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") - .option('--redirect-url ', 'Oauth2.0 Authorized URL') .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') - .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") .addOption(hostOption) .addOption(apiKeyOption) .description('Updates an application with data provided via a file, a property, or a command flag.') From fc07744f6d3f3d22be5f3122b23b09e49b473a38 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 08:51:40 -0400 Subject: [PATCH 23/33] fix(update): changes output variable name to match what comes from commander --- src/commands/application/get.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 993ddf7..3c88d5c 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -13,12 +13,12 @@ export async function executeGet(id:string, options: Record): Promi const { host = 'http://localhost:9011', key, - filePath = `./${id}.json` + output = `./${id}.json` } = options if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") try { - const fullPath = path.resolve(filePath); + const fullPath = path.resolve(output); const httpClient = new HTTPClient(host, key); const response = await httpClient.executeRequest('GET', `/api/application/${id}`) if (response.status != 200) throw response From 13131aa59b23b887d5779de528a965178eccc228 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 09:21:36 -0400 Subject: [PATCH 24/33] fix(update): fixes test for output file --- __tests__/commands/application-get.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/commands/application-get.test.js b/__tests__/commands/application-get.test.js index c4d5fe9..bd95fb8 100644 --- a/__tests__/commands/application-get.test.js +++ b/__tests__/commands/application-get.test.js @@ -70,7 +70,7 @@ describe('application:get options checks', () => { const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `/test-app-${Date.now()}`)) try { - await executeGet(APP_ID, {...BASE_OPTIONS, filePath: tmpRoot + "/myFile.json"}) + await executeGet(APP_ID, {...BASE_OPTIONS, output: tmpRoot + "/myFile.json"}) const fileExists = fs.existsSync(tmpRoot + '/myFile.json') assert.equal(fileExists, true, "Response file not created") } catch(e) { From 4578c0507e942c82a30bc334900fd3a41271993e Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 11:44:07 -0400 Subject: [PATCH 25/33] fix(update): refactors update for errorAndExit --- 3c219e58-ed0e-4b18-ad48-f4f92793ae32.json | 9 ++++ __tests__/commands/application-update.test.js | 49 ++++++++++++++++--- src/commands/application/get.ts | 5 +- src/commands/application/update.ts | 43 ++++++++++------ 4 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 3c219e58-ed0e-4b18-ad48-f4f92793ae32.json diff --git a/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json b/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json new file mode 100644 index 0000000..1212596 --- /dev/null +++ b/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json @@ -0,0 +1,9 @@ +{ + "application": { + "id": "3c219e58-ed0e-4b18-ad48-f4f92793ae32", + "name": "Test App", + "oauthConfiguration": { + "clientId": "3c219e58-ed0e-4b18-ad48-f4f92793ae32" + } + } +} \ No newline at end of file diff --git a/__tests__/commands/application-update.test.js b/__tests__/commands/application-update.test.js index d39f1ee..17673f2 100644 --- a/__tests__/commands/application-update.test.js +++ b/__tests__/commands/application-update.test.js @@ -1,11 +1,10 @@ import { describe, test, beforeEach, afterEach, run } from 'node:test' -import assert, { throws } from 'node:assert/strict' +import assert from 'node:assert/strict' import nock from 'nock' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { action, getData, setNestedProps, splitProp } from '../../src/commands/application/update.js' -import { chdir, cwd } from 'node:process' +import { executeUpdateAction, getData, setNestedProps, splitProp } from '../../src/commands/application/update.js' beforeEach(() => { @@ -42,11 +41,14 @@ const BASE_OPTIONS = { describe("test action function", () => { test("no data or props should error", async () => { - await assert.rejects(() => action(APP_ID, {...BASE_OPTIONS})) + const result = await executeUpdateAction(APP_ID, {...BASE_OPTIONS}) + await assert.equal(result.success, false) }) test("Prop option errors with improper syntax", async () => { - await assert.rejects(() => action(APP_ID, {...BASE_OPTIONS, prop: ["something"]})) + const result = await executeUpdateAction(APP_ID, {...BASE_OPTIONS, prop: ["something"]}) + await assert.equal(result.success, false) + // await assert.rejects(() => executeUpdateAction(APP_ID, {...BASE_OPTIONS, prop: ["something"]})) }) test("errors when response isn't 200", async () => { @@ -64,10 +66,43 @@ describe("test action function", () => { nock(FA_HOST) .patch(`/api/application/${APP_ID}`) .reply(400, APP_RESPONSE) - await assert.rejects(() => action((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}), 'prop option fails') - await assert.rejects(() => action((APP_ID), {...BASE_OPTIONS, data: tmp}), 'Data file fails') + + const propResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}) + const dataResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, data: tmp}) + await assert.equal(propResult.success, false) + await assert.equal(dataResult.success, false) }) + test("success when response is 200 for prop", async () => { + const tmp = path.join(os.tmpdir() + "test.json") + const testJSON = { + application: { + authenticationTokenConfiguration: { + enabled: false + }, + baseURL: "http://myurl.com3" + } + } + fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) + + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const dataResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something2=somethingelse"]}) + await assert.equal(dataResult.success, true) + + }) + test("success when response is 200 for prop", async () => { + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const propResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}) + await assert.equal(propResult.success, true) + + }) + }) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 3c88d5c..96354d3 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -1,5 +1,5 @@ import { Command } from "@commander-js/extra-typings"; -import { __dirname, logEvent } from '../../utils.js' +import { __dirname, logEvent, errorAndExit } from '../../utils.js' import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; @@ -26,8 +26,7 @@ try { writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) console.log(chalk.green(`Response written to `) + fullPath) } catch(e:any) { - console.log(chalk.red("The request produced the following error:\n")) - throw new Error(inspect(e,{showHidden: false, depth: null, colors: true})) + errorAndExit("The request produced the following error:\n", new Error(inspect(e,{showHidden: false, depth: null, colors: true}))) } diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index c254d02..d22c2c9 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -1,5 +1,5 @@ import { Command } from "@commander-js/extra-typings"; -import { __dirname, logEvent } from '../../utils.js' +import { __dirname, errorAndExit, logEvent } from '../../utils.js' import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; @@ -63,7 +63,7 @@ export function splitProp(prop: string) { } } -export const action = async function (id: string, options: Record): Promise { +export const executeUpdateAction = async function (id: string, options: Record) { const { host = 'http://localhost:9011', key @@ -77,10 +77,12 @@ export const action = async function (id: string, options: Record): if (options?.data) { const data = await getData(options.data) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - if (response.status !== 200) throw response.body - console.log(chalk.green(`Applied the following patch\n`), inspect(data, { showHidden: false, depth: null, colors: true })) - return - } + if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) + if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") + return { + success: true, + patchData: data + } } if (options?.prop) { let data = { application: {} } const splitprops = options.prop.map((prop: string) => { @@ -89,24 +91,33 @@ export const action = async function (id: string, options: Record): }) splitprops.forEach((prop: any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - if (response.status !== 200) throw response.body + if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) + if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") - displaySuccess(`Applied the following patch\n${JSON.stringify(data, null, 2)}`) - return + return { + success: true, + patchData: data + } } - } catch (e: any) { - if (e?.fieldErrors || e?.generalErrors) { - console.log(chalk.red('An error ocurred. Patch was not applied. Full error:\n')) - console.log(inspect(e, { showHidden: false, depth: null, colors: true })) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { success: false, error: message, rawError: e }; + } +} - } else { - throw new Error(e) - } +const action = async (id: string, options: Record) => { + + const result = await executeUpdateAction(id, options) + if (!result?.success) { + errorAndExit(result?.error ?? 'Error creating application.', result?.rawError); + return; } + displaySuccess(`Applied the following patch\n${JSON.stringify(result.patchData, null, 2)}`) } + export const appUpdate = new Command() .command('application:update') .argument('', "The FusionAuth Application ID to update") From d53beda974269082c422efadc4ea40557d24aff8 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 14:00:59 -0400 Subject: [PATCH 26/33] fix(update): refactors to result-based return --- src/commands/application/get.ts | 42 ++++++++++++++++++++---------- src/commands/application/update.ts | 6 ++--- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 96354d3..92a8886 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -1,14 +1,13 @@ import { Command } from "@commander-js/extra-typings"; -import { __dirname, logEvent, errorAndExit } from '../../utils.js' +import { __dirname, logEvent, errorAndExit, betaWarning } from '../../utils.js' import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { writeFileSync } from "node:fs"; import chalk from "chalk"; -import { inspect } from "node:util"; -export async function executeGet(id:string, options: Record): Promise { +export async function executeGet(id: string, options: Record) { logEvent("cli application:get") const { host = 'http://localhost:9011', @@ -17,26 +16,42 @@ export async function executeGet(id:string, options: Record): Promi } = options if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") -try { + try { const fullPath = path.resolve(output); const httpClient = new HTTPClient(host, key); const response = await httpClient.executeRequest('GET', `/api/application/${id}`) - if (response.status != 200) throw response + if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) + if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") - writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) - console.log(chalk.green(`Response written to `) + fullPath) - } catch(e:any) { - errorAndExit("The request produced the following error:\n", new Error(inspect(e,{showHidden: false, depth: null, colors: true}))) - } + await writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) + return { + success: true, + fullPath + } + } catch (e: any) { + return { + success: false, + error: e.message, + rawError: e + } + } } -const action = async function (id:string, options: Record): Promise { +const action = async function (id: string, options: Record): Promise { + betaWarning(); logEvent('cli application:get') - - executeGet(id, options) + + const result = await executeGet(id, options) + + if (!result?.success) { + errorAndExit(result?.error ?? "An error ocurred while fetching the response."); + return; + } + console.log(chalk.green(`Response written to `) + result.fullPath) + } export const appGet = new Command() @@ -47,4 +62,3 @@ export const appGet = new Command() .addOption(apiKeyOption) .description('Retrieves an application by id, writing its data to a local file') .action(action) - \ No newline at end of file diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index d22c2c9..b8ec6d6 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -1,5 +1,5 @@ import { Command } from "@commander-js/extra-typings"; -import { __dirname, errorAndExit, logEvent } from '../../utils.js' +import { __dirname, betaWarning, errorAndExit, logEvent } from '../../utils.js' import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; @@ -108,10 +108,10 @@ export const executeUpdateAction = async function (id: string, options: Record) => { - + betaWarning() const result = await executeUpdateAction(id, options) if (!result?.success) { - errorAndExit(result?.error ?? 'Error creating application.', result?.rawError); + errorAndExit(result?.error ?? 'Error updating application.', result?.rawError); return; } displaySuccess(`Applied the following patch\n${JSON.stringify(result.patchData, null, 2)}`) From 0d9731aa72be688e0248e2dc25ef074b646dd16b Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 14:19:27 -0400 Subject: [PATCH 27/33] fix(update): updates splitProp to allow for multiple --- src/commands/application/update.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index b8ec6d6..f8556d5 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -52,12 +52,12 @@ export function isJSON(string: string) { export function splitProp(prop: string) { try { - const [key, value] = prop.split("=") - - if (isJSON(value)) { - return { key, value: JSON.parse(value) } + const [key, ...value] = prop.split("=") + const joinedValue = value.join("=") + if (isJSON(joinedValue)) { + return { key, value: JSON.parse(joinedValue) } } - return { key, value } + return { key, value: joinedValue } } catch (e: any) { throw new Error(e) } From 547f23d685967bbeb71471ad3eaaa645a88de954 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 14:41:03 -0400 Subject: [PATCH 28/33] fix(update): removes response.body in favor of simpler error --- src/commands/application/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index f8556d5..f12c945 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -78,7 +78,7 @@ export const executeUpdateAction = async function (id: string, options: Record Date: Thu, 8 Oct 2026 14:57:40 -0400 Subject: [PATCH 29/33] fix(update): converts default of get to console output --- README.md | 8 +++-- __tests__/commands/application-get.test.js | 21 ++++--------- src/commands/application/get.ts | 34 ++++++++++++++++------ src/commands/application/update.ts | 1 - 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9ea2925..c582eaa 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,16 @@ fusionauth --help; ``` Currently, the CLI supports the following commands: -- Application Update +- Application management - `fusionauth application:update ` - Updates an application with provided data - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update - `-p, --prop ` - Update a single property in the application - - `--redirect-url ` - Update the Authorized redirect URL for your application - - `--example` - Create an example file with editable properties to use in conjunction with the `--data` flag + - `fusionauth application:get ` - Get a JSON representation of an application's settings + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-o, --output` - Optional. Writes the JSON object to the indicated file - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Emails diff --git a/__tests__/commands/application-get.test.js b/__tests__/commands/application-get.test.js index bd95fb8..93b28cb 100644 --- a/__tests__/commands/application-get.test.js +++ b/__tests__/commands/application-get.test.js @@ -42,25 +42,14 @@ const BASE_OPTIONS = { describe('application:get options checks', () => { - test('writes to default file when none provided', async () => { + test('returns data when no output is specified', async () => { nock(FA_HOST) .get(`/api/application/${APP_ID}`) .reply(200, APP_RESPONSE) - if (!fs.existsSync('./tmp')) fs.mkdirSync('./tmp') - const tmp = path.resolve('./tmp') - chdir(tmp) - - try { - await executeGet(APP_ID, BASE_OPTIONS) - const fileExists = fs.existsSync(tmp + '/' + APP_ID + '.json') - assert.equal(fileExists, true, "Response file not created") - } catch(e) { - console.log(e) - } finally { - chdir('../') - fs.rmSync(tmp, {recursive: true}) - nock.en - } + + const result = await executeGet(APP_ID, BASE_OPTIONS) + assert.equal(result.success, true) + assert.deepEqual(result.data, APP_RESPONSE) }) test('writes to specified file when provided', async () => { diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 92a8886..5ad56d2 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -5,6 +5,8 @@ import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { writeFileSync } from "node:fs"; import chalk from "chalk"; +import { stdout } from "node:process"; +import { inspect } from "node:util"; export async function executeGet(id: string, options: Record) { @@ -12,24 +14,32 @@ export async function executeGet(id: string, options: Record) { const { host = 'http://localhost:9011', key, - output = `./${id}.json` + output } = options if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") try { - const fullPath = path.resolve(output); const httpClient = new HTTPClient(host, key); const response = await httpClient.executeRequest('GET', `/api/application/${id}`) if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") + if (output) { + const fullPath = path.resolve(output); + await writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) + return { + success: true, + fullPath + } + } else { + return { + success: true, + data: response.body + } + } - await writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) - return { - success: true, - fullPath - } + } catch (e: any) { return { success: false, @@ -50,8 +60,14 @@ const action = async function (id: string, options: Record): Promis errorAndExit(result?.error ?? "An error ocurred while fetching the response."); return; } - console.log(chalk.green(`Response written to `) + result.fullPath) - + if (result.fullPath) { + console.log(chalk.green(`Response written to `) + result.fullPath) + return + } + if (result.data) { + console.log(result.data) + } + } export const appGet = new Command() diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index f12c945..69fafcd 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -5,7 +5,6 @@ import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; -import { inspect } from "node:util"; export function getData(file: string) { try { From 8448a1cc16d4707abbc22025a5f7fa634108cb1a Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 15:03:49 -0400 Subject: [PATCH 30/33] fix(update): removes ... from prop option --- src/commands/application/update.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index 69fafcd..34c4c6e 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -3,7 +3,7 @@ import { __dirname, betaWarning, errorAndExit, logEvent } from '../../utils.js' import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; -import { readFileSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import chalk from "chalk"; export function getData(file: string) { @@ -121,7 +121,7 @@ export const appUpdate = new Command() .command('application:update') .argument('', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") - .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .addOption(hostOption) .addOption(apiKeyOption) .description('Updates an application with data provided via a file, a property, or a command flag.') From 2c13c599baf4b14cd33fee3ccdc42d5df77121bf Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 15:45:32 -0400 Subject: [PATCH 31/33] fix(update): repaces HTTPClient in get --- src/commands/application/get.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index 5ad56d2..d9b5aff 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -7,6 +7,7 @@ import { writeFileSync } from "node:fs"; import chalk from "chalk"; import { stdout } from "node:process"; import { inspect } from "node:util"; +import { FusionAuthClient } from '@fusionauth/typescript-client'; export async function executeGet(id: string, options: Record) { @@ -19,31 +20,32 @@ export async function executeGet(id: string, options: Record) { if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") try { - const httpClient = new HTTPClient(host, key); - const response = await httpClient.executeRequest('GET', `/api/application/${id}`) - if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) - if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") - + const fusionAuthClient = new FusionAuthClient(key, host); + const res = await fusionAuthClient.retrieveApplication(id) if (output) { const fullPath = path.resolve(output); - await writeFileSync(fullPath, JSON.stringify(response?.body, null, 2)) + await writeFileSync(fullPath, JSON.stringify(res.response, null, 2)) return { success: true, fullPath - } + } } else { return { success: true, - data: response.body + data: res.response } } - - } catch (e: any) { + if (e.statusCode === 404) return { + success: false, + error: "Application does not exist", + rawError: e + } + return { success: false, - error: e.message, + error: `The server responded with an error: ${e.statusCode}`, rawError: e } } @@ -62,12 +64,12 @@ const action = async function (id: string, options: Record): Promis } if (result.fullPath) { console.log(chalk.green(`Response written to `) + result.fullPath) - return + return } if (result.data) { console.log(result.data) } - + } export const appGet = new Command() From 0d10213d4b9abba9507a590cd34c41b69d9e27a1 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 16:08:40 -0400 Subject: [PATCH 32/33] fix(update): converts update to FA client and away from HTTPClient --- src/commands/application/get.ts | 3 --- src/commands/application/update.ts | 31 +++++++++++++++--------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts index d9b5aff..2334824 100644 --- a/src/commands/application/get.ts +++ b/src/commands/application/get.ts @@ -1,12 +1,9 @@ import { Command } from "@commander-js/extra-typings"; import { __dirname, logEvent, errorAndExit, betaWarning } from '../../utils.js' -import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { writeFileSync } from "node:fs"; import chalk from "chalk"; -import { stdout } from "node:process"; -import { inspect } from "node:util"; import { FusionAuthClient } from '@fusionauth/typescript-client'; diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index 34c4c6e..f994d7e 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -1,10 +1,10 @@ import { Command } from "@commander-js/extra-typings"; import { __dirname, betaWarning, errorAndExit, logEvent } from '../../utils.js' -import { HTTPClient } from '../../utilities/apply/http-client.js'; import { apiKeyOption, hostOption } from '../../options.js'; import path from "node:path"; import { readFileSync } from "node:fs"; import chalk from "chalk"; +import { FusionAuthClient } from '@fusionauth/typescript-client'; export function getData(file: string) { try { @@ -67,7 +67,7 @@ export const executeUpdateAction = async function (id: string, options: Record { @@ -89,18 +89,17 @@ export const executeUpdateAction = async function (id: string, options: Record setNestedProps(data.application, prop.key, prop.value)) - const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - if (response.status === 404) throw new Error(`Application with ID ${id} does not exist`) - if (response.status !== 200) throw response.body || new Error("The server responded with an error code ${response.status}") - - + const { response } = await fusionAuthClient.patchApplication(id, data) return { success: true, - patchData: data - } + patchData: data, + response + } } - } catch (e: unknown) { + } catch (e: any) { + if (e.statusCode === 404) throw new Error(`Application with ID ${id} does not exist`) + const message = e instanceof Error ? e.message : String(e); return { success: false, error: message, rawError: e }; } @@ -121,7 +120,7 @@ export const appUpdate = new Command() .command('application:update') .argument('', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") - .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .addOption(hostOption) .addOption(apiKeyOption) .description('Updates an application with data provided via a file, a property, or a command flag.') From 8d63b776e91bc245b3a8575a85981c6ef1b8e12d Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 8 Oct 2026 16:28:31 -0400 Subject: [PATCH 33/33] fix(update): converts getData to parseData from application:create --- __tests__/commands/application-update.test.js | 23 +--------- src/commands/application/update.ts | 45 ++++++++++++++----- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/__tests__/commands/application-update.test.js b/__tests__/commands/application-update.test.js index 17673f2..6a4d177 100644 --- a/__tests__/commands/application-update.test.js +++ b/__tests__/commands/application-update.test.js @@ -4,7 +4,7 @@ import nock from 'nock' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { executeUpdateAction, getData, setNestedProps, splitProp } from '../../src/commands/application/update.js' +import { executeUpdateAction, setNestedProps, splitProp } from '../../src/commands/application/update.js' beforeEach(() => { @@ -107,27 +107,6 @@ describe("test action function", () => { }) describe("test utiltiy functions for update", () => { - test('getData functions', () => { - const tmp = path.join(os.tmpdir() + "test.json") - const testJSON = { - application: { - authenticationTokenConfiguration: { - enabled: false - }, - baseURL: "http://myurl.com3" - } - } - - try { - fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) - const returnedData = getData(tmp) - assert.deepEqual(returnedData, testJSON, "Data doesn't match") - } catch(e) { - console.log(e) - } finally { - fs.rmSync(tmp) - } - }) test("setNestedProps functions properly", () => { let obj = {} const propString = "prop.propString" diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts index f994d7e..7246f08 100644 --- a/src/commands/application/update.ts +++ b/src/commands/application/update.ts @@ -5,18 +5,38 @@ import path from "node:path"; import { readFileSync } from "node:fs"; import chalk from "chalk"; import { FusionAuthClient } from '@fusionauth/typescript-client'; - -export function getData(file: string) { - try { - const fileLoc = path.resolve(file) - const contentBuffer = readFileSync(fileLoc).toString('utf-8') - const contents = JSON.parse(contentBuffer) - return contents - } catch (e: any) { - throw new Error(e) - } +import fs from "node:fs" + +function parseData(data: string) { + let json: string; + if (data.startsWith('@')) { + const filePath = data.slice(1); + try { + json = fs.readFileSync(filePath, 'utf-8'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error reading --data file "${filePath}": ${message}`); + } + } else { + json = data; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error parsing --data JSON: ${message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `--data JSON must be a non-null, non-array object, got ${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.` + ); + } + return parsed; } + + export function setNestedProps(obj: any, path: string, value: any) { /* Takes object and dynamically applies a property at any depth myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} @@ -74,7 +94,8 @@ export const executeUpdateAction = async function (id: string, options: Record) => { export const appUpdate = new Command() .command('application:update') .argument('', "The FusionAuth Application ID to update") - .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") + .option('--data ', "Full application config as inline JSON or @file.json") .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .addOption(hostOption) .addOption(apiKeyOption)