From 21073c3de3c9b6cbb5aa0e1bedcb26b96ba78790 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 01/39] adding workflow test on pull-request --- .github/workflows/test.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 44a5aad..bd8edf9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,10 @@ on: push: pull_request: workflow_dispatch: + pull_request: + branches: + - main + - mcr/add-test-to-commit jobs: test: From 19710286b5269e7ab0d9cd6897c7dcd160d6936b 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 02/39] updating with only main branch for test --- .github/workflows/test.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bd8edf9..f6c91bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - - mcr/add-test-to-commit jobs: test: From 00b7de549a62fa6b699dcb1377ce4acf08180f8f Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:06:38 -0600 Subject: [PATCH 03/39] Trying out --yes on kickstart:kill --- CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..201d505 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## Risky Operations Policy + +Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. + +### Risk Tiers + +**Tier 1 — Irreversible** +Operations that cannot be undone (e.g. deleting an application, deleting a lambda). Recovery requires significant manual effort. + +**Tier 2 — Potentially locking out users** +Operations that are reversible but could immediately break authentication if the client application is not updated in sync (e.g. enabling PKCE on an existing application, changing grant types, rotating a client secret). + +Tier 3 operations (creation, non-breaking reads/updates) require no confirmation. + +### Implementation + +Add `--yes` to the command's options: + +```typescript +.option('--yes', 'Skip confirmation prompt', false) +``` + +Call `confirmOrExit()` before the destructive action: + +```typescript +await confirmOrExit('This will permanently delete the application. This cannot be undone.', yes); +``` + +For Tier 1, the message must describe what will be permanently lost. For Tier 2, use a specific message describing what could break and for whom. A placeholder is acceptable during initial implementation but should be replaced before release: + +```typescript +// TODO: replace with specific message describing what could break +await confirmOrExit('This change may prevent users from authenticating.', yes); +``` + +### Rules + +- Always use `--yes`. Do not use `--force` or `--confirm`. +- Do not add `--yes` to Tier 3 operations. diff --git a/src/utils.ts b/src/utils.ts index c8358f0..208febc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -236,6 +236,42 @@ export async function confirmOrExit(message: string, yes: boolean): Promise { + if (yes) return; + + console.warn(chalk.yellow(message)); + + if (!process.stdout.isTTY) { + errorAndExit('Pass --yes to confirm this operation non-interactively.'); + return; + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve) => { + rl.question('Proceed? [y/N] ', (answer) => { + rl.close(); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + process.exit(0); + } + resolve(); + }); + }); +} + /** * Returns a console log that can be added to a beta feature to warn the user */ From 2e9396eff64613874bfd2dbad2b6197f7e5fe55c Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:39:32 -0600 Subject: [PATCH 04/39] camel-> kebab case, tests --- CONTRIBUTING.md | 52 ++++++++++---------- __tests__/commands/kickstart-install.test.js | 13 +++++ package.json | 8 +-- src/commands/import-generate.ts | 6 +-- 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 201d505..4534148 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,41 +1,43 @@ # Contributing -## Risky Operations Policy - -Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. +## Command Structure +Commands generally follow the form: -### Risk Tiers +fusionauth namespace:command [--command-option] ... -**Tier 1 — Irreversible** -Operations that cannot be undone (e.g. deleting an application, deleting a lambda). Recovery requires significant manual effort. +Where +* Commands are grouped into a functional or domain namespace +* Option names use kebab-case (e.g. `--admin-email`, `--number-of-files`) +* Sensitive items can be passed via environment variable. In this case use `--option-name-env ENV_VAR` to indicate that the value is coming from the specified environment variable -**Tier 2 — Potentially locking out users** -Operations that are reversible but could immediately break authentication if the client application is not updated in sync (e.g. enabling PKCE on an existing application, changing grant types, rotating a client secret). +## Risky Operations Policy -Tier 3 operations (creation, non-breaking reads/updates) require no confirmation. +Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. -### Implementation +## Testing -Add `--yes` to the command's options: +### Running the tests -```typescript -.option('--yes', 'Skip confirmation prompt', false) -``` +```bash +# Unit tests (run these before every commit) +npm run test:unit -Call `confirmOrExit()` before the destructive action: +# Integration tests (requires a live FusionAuth instance) +npm run test:integration -```typescript -await confirmOrExit('This will permanently delete the application. This cannot be undone.', yes); +# Full suite +npm run test ``` -For Tier 1, the message must describe what will be permanently lost. For Tier 2, use a specific message describing what could break and for whom. A placeholder is acceptable during initial implementation but should be replaced before release: +The integration tests manage a Docker container automatically. Several environment variables control their behaviour: -```typescript -// TODO: replace with specific message describing what could break -await confirmOrExit('This change may prevent users from authenticating.', yes); -``` +| Variable | Effect | +|---|---| +| `VERBOSE_CONTAINER=true` | Print each health-check attempt, elapsed time, and error reason; dump `docker compose logs` on failure | +| `REUSE_CONTAINER=true` | Skip container startup and use a FusionAuth instance already running on `localhost:9011` | +| `SKIP_TEARDOWN=true` | Leave the container running after the tests finish (useful for manual inspection) | -### Rules +### Requirements -- Always use `--yes`. Do not use `--force` or `--confirm`. -- Do not add `--yes` to Tier 3 operations. +- **All new functionality must be covered by tests.** This includes new commands, new options on existing commands, and new utility functions. +- **All existing tests must pass cleanly before a PR is submitted.** A clean run means zero failures — `# fail 0` in the test output. diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index cf7ba13..d59a65d 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -15,12 +15,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 +45,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) diff --git a/package.json b/package.json index 77ba0b0..2ef0440 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": { @@ -15,8 +15,10 @@ "copy-files": "cp -r ./src/resources/ ./dist/commands/resources/", "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", + "start": "node --import=tsx src/index.ts", + "test": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/integration/apply/apply.integration.test.js", + "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js", "prepare": "husky" }, "keywords": [ diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 75de421..068c31c 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.` )); } } @@ -105,7 +105,7 @@ function generateData(numObjects: number, appId: string, groupId: string, startN birthDate: faker.date.past().toISOString().split('T')[0], data: { displayName: faker.person.firstName() + ' ' + faker.person.lastName(), - favoriteColors: [faker.color.rgb(), faker.color.rgb()] + favoriteColors: [faker.internet.color(), faker.internet.color()] }, email: `example${i + 1 + startNumber}@example.com`, encryptionScheme: 'salted-pbkdf2-hmac-sha256', @@ -137,7 +137,7 @@ function generateData(numObjects: number, appId: string, groupId: string, startN }, insertInstant: faker.date.past().getTime(), preferredLanguages: ['en_US'], - username: faker.internet.username(), + username: faker.internet.userName(), verified: faker.datatype.boolean() } ], From 8a0e12628869c6fef60ee52ac3b1ab7e482493d2 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:46:26 -0600 Subject: [PATCH 05/39] package-lock version update --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": { From f287f5126e87628035a4ac2545b0a8c3acfda61b Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:18:28 -0600 Subject: [PATCH 06/39] 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) --- src/commands/import-generate.ts | 4 ++-- src/utils.ts | 36 --------------------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 068c31c..6c741d7 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -105,7 +105,7 @@ function generateData(numObjects: number, appId: string, groupId: string, startN birthDate: faker.date.past().toISOString().split('T')[0], data: { displayName: faker.person.firstName() + ' ' + faker.person.lastName(), - favoriteColors: [faker.internet.color(), faker.internet.color()] + favoriteColors: [faker.color.rgb(), faker.color.rgb()] }, email: `example${i + 1 + startNumber}@example.com`, encryptionScheme: 'salted-pbkdf2-hmac-sha256', @@ -137,7 +137,7 @@ function generateData(numObjects: number, appId: string, groupId: string, startN }, insertInstant: faker.date.past().getTime(), preferredLanguages: ['en_US'], - username: faker.internet.userName(), + username: faker.internet.username(), verified: faker.datatype.boolean() } ], diff --git a/src/utils.ts b/src/utils.ts index 208febc..c8358f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -236,42 +236,6 @@ export async function confirmOrExit(message: string, yes: boolean): Promise { - if (yes) return; - - console.warn(chalk.yellow(message)); - - if (!process.stdout.isTTY) { - errorAndExit('Pass --yes to confirm this operation non-interactively.'); - return; - } - - const { createInterface } = await import('node:readline'); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - await new Promise((resolve) => { - rl.question('Proceed? [y/N] ', (answer) => { - rl.close(); - if (answer.toLowerCase() !== 'y') { - console.log('Aborted.'); - process.exit(0); - } - resolve(); - }); - }); -} - /** * Returns a console log that can be added to a beta feature to warn the user */ From af54dea0e8e6c87966fd42712c5a10973164d5a5 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:46:18 -0600 Subject: [PATCH 07/39] 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 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2ef0440..8dffe53 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/integration/apply/apply.integration.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/integration/apply/apply.integration.test.js", "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js", + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js", "prepare": "husky" }, "keywords": [ From 8cb31829483d8699d664e96359b2ca8ac0d0c535 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:08:50 -0600 Subject: [PATCH 08/39] 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 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8dffe53..fb2f20c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/integration/apply/apply.integration.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/integration/apply/apply.integration.test.js", "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js", + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js", "prepare": "husky" }, "keywords": [ From 47a86825f557020ef4e49edf4e2f448d62a6e743 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:29 -0600 Subject: [PATCH 09/39] checkpointing application create - incomplete --- AGENTS.md | 7 +- src/commands/application-create.ts | 196 +++++++++++++++++++++++++++++ src/commands/index.ts | 1 + 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/commands/application-create.ts diff --git a/AGENTS.md b/AGENTS.md index 8649667..61230a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,7 @@ -# 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/`) @@ -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/src/commands/application-create.ts b/src/commands/application-create.ts new file mode 100644 index 0000000..42e17d7 --- /dev/null +++ b/src/commands/application-create.ts @@ -0,0 +1,196 @@ +import * as fs from 'node:fs'; +import {Command, Option} from '@commander-js/extra-typings'; +import { + Application, + ClientAuthenticationPolicy, + FusionAuthClient, + GrantType, + ProofKeyForCodeExchangePolicy, + RefreshTokenExpirationPolicy, + RefreshTokenUsagePolicy, +} from '@fusionauth/typescript-client'; +import chalk from 'chalk'; +import {errorAndExit, logEvent} from '../utils.js'; +import {apiKeyOption, hostOption} from '../options.js'; + +type Profile = 'spa' | 'native' | 'webapp'; + +const publicClientDefaults: Application = { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.Required, + clientAuthenticationPolicy: ClientAuthenticationPolicy.NotRequired, + requireClientAuthentication: false, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 300, + refreshTokenUsagePolicy: RefreshTokenUsagePolicy.OneTimeUse, + refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, + }, +}; + +const profileDefaults: Record = { + spa: publicClientDefaults, + native: publicClientDefaults, + webapp: { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.NotRequiredWhenUsingClientAuthentication, + clientAuthenticationPolicy: ClientAuthenticationPolicy.Required, + requireClientAuthentication: true, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 3600, + refreshTokenUsagePolicy: RefreshTokenUsagePolicy.Reusable, + refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.Fixed, + }, + }, +}; + +/** + * Parses the --data value. If it begins with '@', reads the referenced file. + * Otherwise parses the value as inline JSON. + */ +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); + errorAndExit(`Error reading --data file "${filePath}": ${message}`); + // unreachable — errorAndExit calls process.exit, but satisfies TS + throw e; + } + } else { + json = data; + } + try { + return JSON.parse(json) as Application; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + errorAndExit(`Error parsing --data JSON: ${message}`); + throw e; + } +} + +const action = async function ( + name: string, + { + profile, + redirectUri, + logoutUrl, + authorizedOriginUrl, + applicationId, + tenantId, + data, + key: apiKey, + host, + }: { + profile?: string; + redirectUri?: string[]; + logoutUrl?: string; + authorizedOriginUrl?: string[]; + applicationId?: string; + tenantId?: string; + data?: string; + key: string; + host: string; + } +) { + await logEvent('cli command application:create'); + + // --- Mode validation --- + if (profile && data) { + errorAndExit('--profile and --data are mutually exclusive. Provide one or the other.'); + return; + } + if (!profile && !data) { + errorAndExit('Either --profile or --data is required.'); + return; + } + + let application: Application; + + if (profile) { + // --- Profile mode --- + if (redirectUri === undefined || redirectUri.length === 0) { + errorAndExit('--redirect-uri is required when using --profile.'); + return; + } + + 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 --- + application = parseData(data!); + application.name = name; + } + + // --- ID overrides (applied last in both modes) --- + if (applicationId) { + application.id = applicationId; + } + if (tenantId) { + application.tenantId = tenantId; + } + + // --- API call --- + try { + const fusionAuthClient = new FusionAuthClient(apiKey, host); + const clientResponse = await fusionAuthClient.createApplication( + application.id ?? '', + {application} + ); + + if (!clientResponse.wasSuccessful()) { + errorAndExit('Error creating application: ', clientResponse); + return; + } + + const created = clientResponse.response.application!; + const clientId = created.oauthConfiguration?.clientId ?? created.id ?? ''; + const clientSecret = created.oauthConfiguration?.clientSecret; + + console.log(chalk.green('Application created.')); + console.log(` Name: ${created.name}`); + console.log(` Application ID / client_id: ${clientId}`); + if (clientSecret) { + console.log(` Client Secret: ${clientSecret}`); + } + } catch (e: unknown) { + errorAndExit('Error creating application: ', e); + } +}; + +// noinspection JSUnusedGlobalSymbols +export const applicationCreate = new Command('application:create') + .description('Create an application in FusionAuth') + .argument('', 'The name of the application') + .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 CORS)') + .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)') + .addOption(apiKeyOption) + .addOption(hostOption) + .action(action); 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'; From f85e22054571a8097486ce0ae4bdf2973ad7e434 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:24:42 -0600 Subject: [PATCH 10/39] checkpoint --- __tests__/commands/application-create.test.js | 569 ++++++++++++++++++ .../application-create.integration.test.js | 239 ++++++++ __tests__/integration/setup.js | 132 +++- package.json | 6 +- src/commands/application-create.ts | 259 +++++--- src/index.ts | 1 + 6 files changed, 1120 insertions(+), 86 deletions(-) create mode 100644 __tests__/commands/application-create.test.js create mode 100644 __tests__/integration/application-create/application-create.integration.test.js diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js new file mode 100644 index 0000000..ebe3bac --- /dev/null +++ b/__tests__/commands/application-create.test.js @@ -0,0 +1,569 @@ +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 BASE_OPTIONS = { + name: 'Test App', + key: API_KEY, + host: FA_HOST, +} + +// 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'], + ...overrides, + }, + }, + } +} + +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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + 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/) + }) +}) + +// --------------------------------------------------------------------------- +// --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('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/) + }) +}) + +// --------------------------------------------------------------------------- +// Profile defaults — request body assertions +// --------------------------------------------------------------------------- + +describe('profile defaults', () => { + test('spa profile sends correct oauthConfiguration and jwtConfiguration', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + const jwt = body.application.jwtConfiguration + assert.deepEqual(oauth.enabledGrants, ['authorization_code']) + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + assert.equal(oauth.requireClientAuthentication, false) + assert.equal(oauth.generateRefreshTokens, true) + assert.deepEqual(oauth.authorizedRedirectURLs, ['https://example.com/callback']) + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + }) + + test('native profile sends same defaults as spa', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + return true + }) + .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(jwt.timeToLiveInSeconds, 3600) + assert.equal(jwt.refreshTokenUsagePolicy, 'Reusable') + assert.equal(jwt.refreshTokenExpirationPolicy, 'Fixed') + return true + }) + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + }) + 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({ + ...BASE_OPTIONS, + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + }) + + test('optional profile options are included when provided', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + 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 () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + tenantId: TENANT_ID, + }) + assert.equal(result.success, true) + }) + + test('X-FusionAuth-TenantId is present on createApplication call when --tenant-id supplied', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST, { + reqheaders: { 'x-fusionauth-tenantid': TENANT_ID }, + }) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, false) + }) + + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, false) + }) +}) + +// --------------------------------------------------------------------------- +// 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + 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'], + })) + + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// 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({ + ...BASE_OPTIONS, + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('clientSecret is absent for spa profile (public client)', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) // no clientSecret in response + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + assert.equal(result.clientSecret, undefined) + }) + + test('result contains name, applicationId, and clientId', 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, true) + assert.equal(result.name, 'Test App') + assert.equal(result.applicationId, APP_ID) + assert.equal(result.clientId, APP_ID) + }) +}) 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..8fb5d9d --- /dev/null +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -0,0 +1,239 @@ +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'] + +describe('application:create integration tests', () => { + let fusionAuthUrl + let apiKey + const createdApplicationIds = [] + + before(async () => { + const container = await startFusionAuthContainer() + fusionAuthUrl = container.url + apiKey = container.apiKey + 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, + ...overrides, + } + } + + // --------------------------------------------------------------------------- + // Happy paths — one per profile + // --------------------------------------------------------------------------- + + test('--profile spa creates application with correct settings and configures CORS', async () => { + const result = await executeApplicationCreate(baseOptions({ + profile: 'spa', + redirectUri: ['https://example.com/callback'], + })) + + 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.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code']) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['https://example.com/callback']) + 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 + 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) + }) + + test('--profile native creates application with correct settings and configures CORS', 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.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['myapp://callback']) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + + // CORS must also be configured for native + 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}'`) + } + }) + + test('--profile webapp creates confidential client and returns clientSecret', async () => { + const result = await executeApplicationCreate(baseOptions({ + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + })) + + 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.jwtConfiguration.timeToLiveInSeconds, 3600) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'Reusable') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'Fixed') + }) + + 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(baseOptions({ + profile: 'spa', + redirectUri: ['https://example.com/callback'], + })) + assert.equal(result1.success, true) + createdApplicationIds.push(result1.applicationId) + + // Second create without resetting CORS + const result2 = await executeApplicationCreate(baseOptions({ + profile: 'spa', + 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. + const result = await executeApplicationCreate(baseOptions({ + profile: 'spa', + redirectUri: ['https://example.com/callback'], + tenantId: TENANT_ID, + })) + 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(baseOptions({ + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + 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/setup.js b/__tests__/integration/setup.js index f27ae2e..33ac014 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -16,13 +16,62 @@ 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 +const CONTAINER_NAME = 'fusionauth-integration-test-base-fusionauth-1' let isContainerRunning = false +let resolvedFusionAuthUrl = DEFAULT_FUSIONAUTH_URL + +/** + * 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 { stdout } = await execAsync( + `docker inspect ${CONTAINER_NAME} --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,7 +80,8 @@ 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...') @@ -75,10 +125,13 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m // 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}`) } @@ -124,13 +177,15 @@ 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. + // We check using the container IP directly (more reliable than localhost + // on macOS Docker Desktop where port-mapping affects auth behavior). let authReady = false const authStartTime = Date.now() @@ -138,8 +193,18 @@ async function waitForFusionAuthReady() { try { const authController = new AbortController() const authTimeoutId = setTimeout(() => authController.abort(), 5000) + + // Try localhost first, fall back to container IP check via Docker inspect + let checkUrl = DEFAULT_FUSIONAUTH_URL + try { + const { stdout } = await execAsync( + `docker inspect ${CONTAINER_NAME} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ip = stdout.trim().split(/\s+/).filter(Boolean)[0] + if (ip) checkUrl = `http://${ip}:9011` + } catch (_) {} - const tenantsResponse = await fetch(`${FUSIONAUTH_URL}/api/tenant`, { + const tenantsResponse = await fetch(`${checkUrl}/api/tenant`, { method: 'GET', headers: { Authorization: DEFAULT_API_KEY }, signal: authController.signal @@ -182,7 +247,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' @@ -269,6 +334,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/native). + * @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/package.json b/package.json index fb2f20c..8da15a6 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/integration/apply/apply.integration.test.js", - "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/commands/application-create.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/application-create/application-create.integration.test.js", + "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/application-create/application-create.integration.test.js", + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/commands/application-create.test.js", "prepare": "husky" }, "keywords": [ diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 42e17d7..ed84aef 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -3,6 +3,7 @@ import {Command, Option} from '@commander-js/extra-typings'; import { Application, ClientAuthenticationPolicy, + CORSConfiguration, FusionAuthClient, GrantType, ProofKeyForCodeExchangePolicy, @@ -12,9 +13,32 @@ import { import chalk from 'chalk'; import {errorAndExit, 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; + key: string; + host: string; +} + +export interface ApplicationCreateResult { + success: boolean; + error?: string; + applicationId?: string; + clientId?: string; + clientSecret?: string; + name?: string; +} + const publicClientDefaults: Application = { oauthConfiguration: { enabledGrants: [GrantType.authorization_code], @@ -51,9 +75,80 @@ const profileDefaults: Record = { }, }; +const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept']; + +/** + * Ensures that the required DPoP-related CORS headers are present in the + * FusionAuth system configuration. Also enables CORS if it is currently + * disabled. Should be called for spa and native profiles before creating + * the application. + * + * CORS is a prerequisite for spa/native DPoP flows. If this call fails the + * entire command is aborted — no application will be created. + * + * 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): Promise { + const originalTenantId = client.tenantId ?? null; + client.setTenantId(null); + + try { + let retrieveResponse; + try { + retrieveResponse = await client.retrieveSystemConfiguration(); + } catch (e: unknown) { + throw new Error(`Error retrieving system configuration: ${e instanceof Error ? e.message : String(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()) + ); + + const needsEnable = cors.enabled !== true; + + if (missing.length === 0 && !needsEnable) { + return; + } + + if (needsEnable) { + console.warn(chalk.yellow( + 'Warning: CORS was disabled and has been enabled to support this application. ' + + 'This may allow cross-domain requests that were previously blocked.' + )); + } + + try { + await client.patchSystemConfiguration({ + systemConfiguration: { + corsConfiguration: { + ...cors, + enabled: true, + allowedHeaders: [...existing, ...missing], + }, + }, + }); + + if (missing.length > 0) { + console.log(` CORS headers added: ${missing.join(', ')}`); + } + } catch (e: unknown) { + throw new Error(`Error updating CORS configuration: ${e instanceof Error ? e.message : String(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 or file-read failure (caught by executeApplicationCreate). */ function parseData(data: string): Application { let json: string; @@ -63,9 +158,7 @@ function parseData(data: string): Application { json = fs.readFileSync(filePath, 'utf-8'); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - errorAndExit(`Error reading --data file "${filePath}": ${message}`); - // unreachable — errorAndExit calls process.exit, but satisfies TS - throw e; + throw new Error(`Error reading --data file "${filePath}": ${message}`); } } else { json = data; @@ -74,14 +167,17 @@ function parseData(data: string): Application { return JSON.parse(json) as Application; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - errorAndExit(`Error parsing --data JSON: ${message}`); - throw e; + throw new Error(`Error parsing --data JSON: ${message}`); } } -const action = async function ( - name: string, - { +/** + * Core logic for application:create. Returns a result object rather than + * calling process.exit(), allowing tests to import and invoke this directly. + */ +export async function executeApplicationCreate(options: ApplicationCreateOptions): Promise { + const { + name, profile, redirectUri, logoutUrl, @@ -91,96 +187,109 @@ const action = async function ( data, key: apiKey, host, - }: { - profile?: string; - redirectUri?: string[]; - logoutUrl?: string; - authorizedOriginUrl?: string[]; - applicationId?: string; - tenantId?: string; - data?: string; - key: string; - host: string; - } -) { - await logEvent('cli command application:create'); + } = options; - // --- Mode validation --- - if (profile && data) { - errorAndExit('--profile and --data are mutually exclusive. Provide one or the other.'); - return; - } - if (!profile && !data) { - errorAndExit('Either --profile or --data is required.'); - return; - } + try { + await logEvent('cli command application:create'); - let application: Application; + // --- 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.' }; + } - if (profile) { - // --- Profile mode --- - if (redirectUri === undefined || redirectUri.length === 0) { - errorAndExit('--redirect-uri is required when using --profile.'); - return; + let application: Application; + + if (profile) { + // --- Profile mode --- + if (redirectUri === undefined || redirectUri.length === 0) { + return { success: false, error: '--redirect-uri is required when using --profile.' }; + } + + 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 --- + application = parseData(data!); + application.name = name; } - 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 --- - application = parseData(data!); - application.name = name; - } + // --- ID overrides (applied last in both modes) --- + if (applicationId) { + application.id = applicationId; + } + if (tenantId) { + application.tenantId = tenantId; + } - // --- 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 spa/native profiles, enforce DPoP-required CORS headers first. + // If this fails the command aborts — createApplication is never called. + if (profile === 'spa' || profile === 'native') { + await ensureCorsHeaders(fusionAuthClient); + } - // --- API call --- - try { - const fusionAuthClient = new FusionAuthClient(apiKey, host); const clientResponse = await fusionAuthClient.createApplication( application.id ?? '', {application} ); - if (!clientResponse.wasSuccessful()) { - errorAndExit('Error creating application: ', clientResponse); - return; - } - const created = clientResponse.response.application!; const clientId = created.oauthConfiguration?.clientId ?? created.id ?? ''; const clientSecret = created.oauthConfiguration?.clientSecret; - console.log(chalk.green('Application created.')); - console.log(` Name: ${created.name}`); - console.log(` Application ID / client_id: ${clientId}`); - if (clientSecret) { - console.log(` Client Secret: ${clientSecret}`); - } + return { + success: true, + applicationId: created.id, + clientId, + clientSecret, + name: created.name, + }; + } catch (e: unknown) { - errorAndExit('Error creating application: ', e); + const message = e instanceof Error ? e.message : String(e); + if (process.env.NODE_ENV !== 'test') { + utils.errorAndExit('Error creating application: ', e); + } + return { success: false, error: message }; + } +} + +/** + * CLI action wrapper — calls executeAction and handles output/exit. + */ +const action = async function (options: ApplicationCreateOptions) { + const result = await executeApplicationCreate(options); + + if (!result.success) { + utils.errorAndExit(result.error ?? 'Error creating application.'); + return; + } + + 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}`); } }; // noinspection JSUnusedGlobalSymbols export const applicationCreate = new Command('application:create') .description('Create an application in FusionAuth') - .argument('', 'The name of the application') + .requiredOption('--name ', 'The name of the application') .addOption( new Option('--profile ', 'Security profile to apply (mutually exclusive with --data)') .choices(['spa', 'native', 'webapp'] as const) 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) => { From 93de60b0a856c492e709597d4f382a2ed569a804 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:49:27 -0600 Subject: [PATCH 11/39] Added next steps. --- __tests__/commands/application-create.test.js | 10 +++-- .../application-create.integration.test.js | 9 ++-- src/commands/application-create.ts | 42 ++++++++++++++++--- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index ebe3bac..8b2c0cd 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -166,11 +166,12 @@ describe('profile defaults', () => { .post('/api/application/', (body) => { const oauth = body.application.oauthConfiguration const jwt = body.application.jwtConfiguration - assert.deepEqual(oauth.enabledGrants, ['authorization_code']) + 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, ['https://example.com/callback']) assert.equal(jwt.enabled, true) assert.equal(jwt.timeToLiveInSeconds, 300) @@ -198,6 +199,7 @@ describe('profile defaults', () => { 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) @@ -218,9 +220,11 @@ describe('profile defaults', () => { 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, 'Reusable') - assert.equal(jwt.refreshTokenExpirationPolicy, 'Fixed') + assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(jwt.refreshTokenExpirationPolicy, 'SlidingWindow') return true }) .reply(200, APP_RESPONSE_WITH_SECRET) diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index 8fb5d9d..8cfde6d 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -74,7 +74,8 @@ describe('application:create integration tests', () => { assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') assert.equal(app.oauthConfiguration.requireClientAuthentication, false) assert.equal(app.oauthConfiguration.generateRefreshTokens, true) - assert.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code']) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code', 'refresh_token']) assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['https://example.com/callback']) assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') @@ -105,6 +106,7 @@ describe('application:create integration tests', () => { 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) @@ -132,9 +134,10 @@ describe('application:create integration tests', () => { 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, 'Reusable') - assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'Fixed') + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') }) test('--data custom mode creates application with provided configuration', async () => { diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index ed84aef..332759d 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import {Command, Option} from '@commander-js/extra-typings'; +import boxen from 'boxen'; import { Application, ClientAuthenticationPolicy, @@ -39,19 +40,27 @@ export interface ApplicationCreateResult { name?: string; } +// Shared refresh token policy for all profiles. A sliding window of +// one-time-use refresh tokens is the recommended default across +// spa/native/webapp — only timeToLiveInSeconds differs per profile. +const defaultRefreshTokenPolicy = { + refreshTokenUsagePolicy: RefreshTokenUsagePolicy.OneTimeUse, + refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, +}; + const publicClientDefaults: Application = { oauthConfiguration: { - enabledGrants: [GrantType.authorization_code], + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], generateRefreshTokens: true, proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.Required, clientAuthenticationPolicy: ClientAuthenticationPolicy.NotRequired, requireClientAuthentication: false, + requireRegistration: true, }, jwtConfiguration: { enabled: true, timeToLiveInSeconds: 300, - refreshTokenUsagePolicy: RefreshTokenUsagePolicy.OneTimeUse, - refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, + ...defaultRefreshTokenPolicy, }, }; @@ -60,17 +69,17 @@ const profileDefaults: Record = { native: publicClientDefaults, webapp: { oauthConfiguration: { - enabledGrants: [GrantType.authorization_code], + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], generateRefreshTokens: true, proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.NotRequiredWhenUsingClientAuthentication, clientAuthenticationPolicy: ClientAuthenticationPolicy.Required, requireClientAuthentication: true, + requireRegistration: true, }, jwtConfiguration: { enabled: true, timeToLiveInSeconds: 3600, - refreshTokenUsagePolicy: RefreshTokenUsagePolicy.Reusable, - refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.Fixed, + ...defaultRefreshTokenPolicy, }, }, }; @@ -284,6 +293,27 @@ const action = async function (options: ApplicationCreateOptions) { if (result.clientSecret) { console.log(` Client Secret: ${result.clientSecret}`); } + + 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 From 297c9cd37ee0c5d2693f594a1a192b3d78d88b26 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:27:56 -0600 Subject: [PATCH 12/39] code review updates --- src/commands/application-create.ts | 43 ++++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 332759d..43bff5d 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -48,25 +48,33 @@ const defaultRefreshTokenPolicy = { refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, }; -const publicClientDefaults: Application = { - 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, - }, -}; +// 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: publicClientDefaults, - native: publicClientDefaults, + spa: buildPublicClientDefaults(), + native: buildPublicClientDefaults(), webapp: { oauthConfiguration: { enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], @@ -74,6 +82,7 @@ const profileDefaults: Record = { proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.NotRequiredWhenUsingClientAuthentication, clientAuthenticationPolicy: ClientAuthenticationPolicy.Required, requireClientAuthentication: true, + // See comment on buildPublicClientDefaults() above re: requireRegistration. requireRegistration: true, }, jwtConfiguration: { From 26d73e064ac439dd950fc641b7f8db1418ed5a76 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:51 -0600 Subject: [PATCH 13/39] =?UTF-8?q?Potential=20fix=20for=20pull=20request=20?= =?UTF-8?q?finding=20'Use=20=E2=80=9Cdoes=20not=20exist=E2=80=9D=20for=20g?= =?UTF-8?q?rammatical=20agreement'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/kickstart-install.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 52aaf8c..b207bbb 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -213,7 +213,7 @@ const action = async function (dir: string, options: InstallOptions) { 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.`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exist.`)) await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) const postgresPass = randomUUID() From 178028e6c07c24a6420d35c10274706f5963f704 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:21:51 -0600 Subject: [PATCH 14/39] 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> --- src/commands/application-create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 43bff5d..deac231 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -286,7 +286,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions } /** - * CLI action wrapper — calls executeAction and handles output/exit. + * CLI action wrapper — calls executeApplicationCreate and handles output/exit. */ const action = async function (options: ApplicationCreateOptions) { const result = await executeApplicationCreate(options); From 92cc0f26cba09866e3bfcb2ed96431e207447349 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:54:46 -0600 Subject: [PATCH 15/39] 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 --- __tests__/commands/application-create.test.js | 102 ++++++++++++++++++ .../application-create.integration.test.js | 4 + __tests__/integration/setup.js | 67 +++++++----- src/commands/application-create.ts | 58 ++++++---- 4 files changed, 185 insertions(+), 46 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 8b2c0cd..f44e76f 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -95,6 +95,17 @@ describe('mode validation', () => { 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 } = BASE_OPTIONS + const result = await executeApplicationCreate({ + ...optionsWithoutName, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, false) + assert.match(result.error, /--name is required/) + }) }) // --------------------------------------------------------------------------- @@ -150,6 +161,38 @@ describe('--data parsing', () => { 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) + }) }) // --------------------------------------------------------------------------- @@ -425,6 +468,7 @@ describe('regression: error attribution', () => { ...BASE_OPTIONS, profile: 'spa', redirectUri: ['https://example.com/callback'], + yes: true, }) assert.equal(result.success, false) }) @@ -482,6 +526,7 @@ describe('CORS header management', () => { ...BASE_OPTIONS, profile: 'spa', redirectUri: ['https://example.com/callback'], + yes: true, }) assert.equal(result.success, true) }) @@ -509,11 +554,68 @@ describe('CORS header management', () => { ...BASE_OPTIONS, profile: 'spa', redirectUri: ['https://example.com/callback'], + yes: true, }) assert.equal(result.success, true) }) }) +// --------------------------------------------------------------------------- +// Confirmation gate (--yes) for CORS mutation +// Per CONTRIBUTING.md's Risky Operations Policy, 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, + // per its documented contract of never exiting the process itself. + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + }) + + 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({ + ...BASE_OPTIONS, + profile: 'spa', + redirectUri: ['https://example.com/callback'], + yes: true, + }) + + assert.equal(result.success, true) + }) +}) + // --------------------------------------------------------------------------- // Output — clientSecret presence/absence // --------------------------------------------------------------------------- diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index 8cfde6d..a723014 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -45,6 +45,10 @@ describe('application:create integration tests', () => { key: apiKey, host: fusionAuthUrl, tenantId: TENANT_ID, + // Bypasses the CORS-change confirmation prompt (spa/native profiles). + // The confirmation gate itself is covered by unit tests; these + // integration tests are focused on real API behavior. + yes: true, ...overrides, } } diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index 33ac014..5afec2f 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -183,42 +183,53 @@ async function waitForFusionAuthReady() { clearTimeout(timeoutId) if (response.ok) { - // Verify the kickstart has run and the container is fully initialized. - // We check using the container IP directly (more reliable than localhost - // on macOS Docker Desktop where port-mapping affects auth behavior). + // 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) - - // Try localhost first, fall back to container IP check via Docker inspect - let checkUrl = DEFAULT_FUSIONAUTH_URL + const { stdout } = await execAsync( + `docker inspect ${CONTAINER_NAME} --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 { stdout } = await execAsync( - `docker inspect ${CONTAINER_NAME} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` - ) - const ip = stdout.trim().split(/\s+/).filter(Boolean)[0] - if (ip) checkUrl = `http://${ip}:9011` - } catch (_) {} - - 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 + 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) } diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index deac231..0864dca 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -12,14 +12,14 @@ import { RefreshTokenUsagePolicy, } from '@fusionauth/typescript-client'; import chalk from 'chalk'; -import {errorAndExit, logEvent} from '../utils.js'; +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; + name?: string; profile?: string; redirectUri?: string[]; logoutUrl?: string; @@ -27,6 +27,7 @@ export interface ApplicationCreateOptions { applicationId?: string; tenantId?: string; data?: string; + yes?: boolean; key: string; host: string; } @@ -34,6 +35,7 @@ export interface ApplicationCreateOptions { export interface ApplicationCreateResult { success: boolean; error?: string; + rawError?: unknown; applicationId?: string; clientId?: string; clientSecret?: string; @@ -104,10 +106,14 @@ const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept']; * CORS is a prerequisite for spa/native DPoP flows. If this call fails the * entire command is aborted — no application will be created. * + * This mutates system-wide configuration, so per the Risky Operations Policy + * (CONTRIBUTING.md) 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): Promise { +async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promise { const originalTenantId = client.tenantId ?? null; client.setTenantId(null); @@ -134,12 +140,16 @@ async function ensureCorsHeaders(client: FusionAuthClient): Promise { return; } - if (needsEnable) { - console.warn(chalk.yellow( - 'Warning: CORS was disabled and has been enabled to support this application. ' + - 'This may allow cross-domain requests that were previously blocked.' - )); - } + const changes = [ + ...(needsEnable ? ['enable CORS'] : []), + ...(missing.length > 0 ? [`add CORS header(s): ${missing.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({ @@ -203,6 +213,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions applicationId, tenantId, data, + yes, key: apiKey, host, } = options; @@ -225,6 +236,9 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions 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.' }; + } const defaults = profileDefaults[profile as Profile]; application = {...defaults}; @@ -239,8 +253,13 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions }; } else { // --- Custom mode --- + // --name is optional here so --data can provide "full custom control": + // only override the JSON's name field if --name was explicitly passed. + // If neither supplies a name, FusionAuth's API will reject the request. application = parseData(data!); - application.name = name; + if (name) { + application.name = name; + } } // --- ID overrides (applied last in both modes) --- @@ -254,9 +273,10 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions const fusionAuthClient = new FusionAuthClient(apiKey, host, tenantId); // For spa/native profiles, enforce DPoP-required CORS headers first. - // If this fails the command aborts — createApplication is never called. + // If this fails (or the user declines the confirmation prompt), the + // command aborts — createApplication is never called. if (profile === 'spa' || profile === 'native') { - await ensureCorsHeaders(fusionAuthClient); + await ensureCorsHeaders(fusionAuthClient, yes ?? false); } const clientResponse = await fusionAuthClient.createApplication( @@ -265,6 +285,10 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions ); 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; @@ -278,10 +302,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - if (process.env.NODE_ENV !== 'test') { - utils.errorAndExit('Error creating application: ', e); - } - return { success: false, error: message }; + return { success: false, error: `Error creating application: ${message}`, rawError: e }; } } @@ -292,7 +313,7 @@ const action = async function (options: ApplicationCreateOptions) { const result = await executeApplicationCreate(options); if (!result.success) { - utils.errorAndExit(result.error ?? 'Error creating application.'); + utils.errorAndExit(result.error ?? 'Error creating application.', result.rawError); return; } @@ -328,7 +349,7 @@ const action = async function (options: ApplicationCreateOptions) { // noinspection JSUnusedGlobalSymbols export const applicationCreate = new Command('application:create') .description('Create an application in FusionAuth') - .requiredOption('--name ', 'The name of the application') + .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) @@ -339,6 +360,7 @@ export const applicationCreate = new Command('application:create') .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/native profiles)', false) .addOption(apiKeyOption) .addOption(hostOption) .action(action); From b6f1ee4cc87ffa7985282588049ce3d801aa93eb Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:47:01 -0600 Subject: [PATCH 16/39] 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. --- .github/workflows/test.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f6c91bf..44a5aad 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,9 +4,6 @@ on: push: pull_request: workflow_dispatch: - pull_request: - branches: - - main jobs: test: From 4afac70da9b8ddc7f644e114f28c1f6f5dde9a91 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:26:33 -0600 Subject: [PATCH 17/39] 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 --- README.md | 3 ++ __tests__/commands/application-create.test.js | 21 +++++++++ src/commands/application-create.ts | 15 ++++-- src/commands/kickstart-install.ts | 46 +++++++++++-------- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 20cbbf6..d52abe2 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 + - `fusionauth application:create --name --profile --redirect-uri ` - 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, for spa/native). + - `fusionauth application:create --name --data ` - Create an application from a full custom JSON configuration, for cases the standard profiles don't cover. - 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 index f44e76f..5123a9e 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -453,6 +453,10 @@ describe('regression: error attribution', () => { redirectUri: ['https://example.com/callback'], }) 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 () => { @@ -471,6 +475,23 @@ describe('regression: error attribution', () => { 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({ + ...BASE_OPTIONS, + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + }) + assert.equal(result.success, false) + assert.match(result.error, /Error creating application/) }) }) diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 0864dca..d02acd4 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -279,10 +279,15 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions await ensureCorsHeaders(fusionAuthClient, yes ?? false); } - const clientResponse = await fusionAuthClient.createApplication( - application.id ?? '', - {application} - ); + let clientResponse; + try { + clientResponse = await fusionAuthClient.createApplication( + application.id ?? '', + {application} + ); + } catch (e: unknown) { + throw new Error(`Error creating application: ${e instanceof Error ? e.message : String(e)}`); + } const created = clientResponse.response.application!; // clientId intentionally mirrors applicationId here: FusionAuth does not @@ -302,7 +307,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - return { success: false, error: `Error creating application: ${message}`, rawError: e }; + return { success: false, error: message, rawError: e }; } } diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index b207bbb..88595c1 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -206,25 +206,33 @@ 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 exist.`)) - 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 + 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 exist.`)) + 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) { + // 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) From 98547b23f2363b6076fd18234e8e75554af812f9 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:03:05 -0600 Subject: [PATCH 18/39] 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 --- README.md | 2 +- __tests__/commands/application-create.test.js | 19 +++++++++++ src/commands/application-create.ts | 33 ++++++++++++++++--- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d52abe2..9fb0071 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Currently, the CLI supports the following commands: - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Applications - `fusionauth application:create --name --profile --redirect-uri ` - 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, for spa/native). - - `fusionauth application:create --name --data ` - Create an application from a full custom JSON configuration, for cases the standard profiles don't cover. + - `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 index 5123a9e..3765364 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -493,6 +493,25 @@ describe('regression: error attribution', () => { 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({ + ...BASE_OPTIONS, + profile: 'webapp', + redirectUri: ['https://example.com/callback'], + }) + 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' }] } }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index d02acd4..7db87dc 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -97,6 +97,31 @@ const profileDefaults: Record = { const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept']; +/** + * 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. Falls back to the error itself + * when there's no cause (e.g. errors that were never wrapped). + */ +function unwrapError(e: unknown): unknown { + if (e instanceof Error && 'cause' in e && e.cause !== undefined) { + return e.cause; + } + return e; +} + /** * Ensures that the required DPoP-related CORS headers are present in the * FusionAuth system configuration. Also enables CORS if it is currently @@ -122,7 +147,7 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promis try { retrieveResponse = await client.retrieveSystemConfiguration(); } catch (e: unknown) { - throw new Error(`Error retrieving system configuration: ${e instanceof Error ? e.message : String(e)}`); + throw wrapError(`Error retrieving system configuration: ${e instanceof Error ? e.message : String(e)}`, e); } const systemConfig = retrieveResponse.response.systemConfiguration!; @@ -166,7 +191,7 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promis console.log(` CORS headers added: ${missing.join(', ')}`); } } catch (e: unknown) { - throw new Error(`Error updating CORS configuration: ${e instanceof Error ? e.message : String(e)}`); + throw wrapError(`Error updating CORS configuration: ${e instanceof Error ? e.message : String(e)}`, e); } } finally { client.setTenantId(originalTenantId); @@ -286,7 +311,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions {application} ); } catch (e: unknown) { - throw new Error(`Error creating application: ${e instanceof Error ? e.message : String(e)}`); + throw wrapError(`Error creating application: ${e instanceof Error ? e.message : String(e)}`, e); } const created = clientResponse.response.application!; @@ -307,7 +332,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - return { success: false, error: message, rawError: e }; + return { success: false, error: message, rawError: unwrapError(e) }; } } From dd602178fbad73411186ccc7e1d31ca99c9318a6 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:20:30 -0600 Subject: [PATCH 19/39] fix: tolerate empty response bodies in integration test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CONTRIBUTING.md | 43 ---------------------------------- __tests__/integration/setup.js | 10 +++++++- 2 files changed, 9 insertions(+), 44 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4534148..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,43 +0,0 @@ -# Contributing - -## Command Structure -Commands generally follow the form: - -fusionauth namespace:command [--command-option] ... - -Where -* Commands are grouped into a functional or domain namespace -* Option names use kebab-case (e.g. `--admin-email`, `--number-of-files`) -* Sensitive items can be passed via environment variable. In this case use `--option-name-env ENV_VAR` to indicate that the value is coming from the specified environment variable - -## Risky Operations Policy - -Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. - -## Testing - -### Running the tests - -```bash -# Unit tests (run these before every commit) -npm run test:unit - -# Integration tests (requires a live FusionAuth instance) -npm run test:integration - -# Full suite -npm run test -``` - -The integration tests manage a Docker container automatically. Several environment variables control their behaviour: - -| Variable | Effect | -|---|---| -| `VERBOSE_CONTAINER=true` | Print each health-check attempt, elapsed time, and error reason; dump `docker compose logs` on failure | -| `REUSE_CONTAINER=true` | Skip container startup and use a FusionAuth instance already running on `localhost:9011` | -| `SKIP_TEARDOWN=true` | Leave the container running after the tests finish (useful for manual inspection) | - -### Requirements - -- **All new functionality must be covered by tests.** This includes new commands, new options on existing commands, and new utility functions. -- **All existing tests must pass cleanly before a PR is submitted.** A clean run means zero failures — `# fail 0` in the test output. diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index 5afec2f..c4bec2c 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -288,7 +288,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}`) From c2871f9617a6222bd7371862743dc402b4f88fbf Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:22:10 -0600 Subject: [PATCH 20/39] 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. --- __tests__/commands/application-create.test.js | 4 ++-- src/commands/application-create.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 3765364..0d7d119 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -602,8 +602,8 @@ describe('CORS header management', () => { // --------------------------------------------------------------------------- // Confirmation gate (--yes) for CORS mutation -// Per CONTRIBUTING.md's Risky Operations Policy, mutating system-wide CORS -// configuration must be gated behind confirmOrExit()/--yes. +// Mutating system-wide CORS configuration must be gated behind +// confirmOrExit()/--yes. // --------------------------------------------------------------------------- describe('confirmation gate for CORS mutation', () => { diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 7db87dc..6279c97 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -131,9 +131,8 @@ function unwrapError(e: unknown): unknown { * CORS is a prerequisite for spa/native DPoP flows. If this call fails the * entire command is aborted — no application will be created. * - * This mutates system-wide configuration, so per the Risky Operations Policy - * (CONTRIBUTING.md) it is gated behind confirmOrExit()/--yes and only prompts - * when a change is actually needed. + * 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. From 32b279bb76496116c22b6ab3a0368c2019e03513 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:49:20 -0600 Subject: [PATCH 21/39] test: reduce duplication in application-create.test.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- __tests__/commands/application-create.test.js | 190 +++++------------- 1 file changed, 47 insertions(+), 143 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 0d7d119..cc6c3d1 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -10,6 +10,7 @@ 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', @@ -17,6 +18,14 @@ const BASE_OPTIONS = { 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: { @@ -53,6 +62,14 @@ function systemConfigResponse(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() @@ -70,9 +87,7 @@ afterEach(() => { describe('mode validation', () => { test('both --profile and --data provided returns error without making API calls', async () => { const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], + ...spaOptions(), data: '{"name":"x"}', }) assert.equal(result.success, false) @@ -97,12 +112,8 @@ describe('mode validation', () => { }) test('--profile without --name returns error without making API calls', async () => { - const { name, ...optionsWithoutName } = BASE_OPTIONS - const result = await executeApplicationCreate({ - ...optionsWithoutName, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + const { name, ...optionsWithoutName } = spaOptions() + const result = await executeApplicationCreate(optionsWithoutName) assert.equal(result.success, false) assert.match(result.error, /--name is required/) }) @@ -201,9 +212,7 @@ describe('--data parsing', () => { describe('profile defaults', () => { test('spa profile sends correct oauthConfiguration and jwtConfiguration', async () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + mockCompliantSystemConfig() nock(FA_HOST) .post('/api/application/', (body) => { @@ -215,7 +224,7 @@ describe('profile defaults', () => { assert.equal(oauth.requireClientAuthentication, false) assert.equal(oauth.generateRefreshTokens, true) assert.equal(oauth.requireRegistration, true) - assert.deepEqual(oauth.authorizedRedirectURLs, ['https://example.com/callback']) + assert.deepEqual(oauth.authorizedRedirectURLs, [REDIRECT_URI]) assert.equal(jwt.enabled, true) assert.equal(jwt.timeToLiveInSeconds, 300) assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') @@ -224,18 +233,12 @@ describe('profile defaults', () => { }) .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(spaOptions()) assert.equal(result.success, true) }) test('native profile sends same defaults as spa', async () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + mockCompliantSystemConfig() nock(FA_HOST) .post('/api/application/', (body) => { @@ -272,11 +275,7 @@ describe('profile defaults', () => { }) .reply(200, APP_RESPONSE_WITH_SECRET) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(webappOptions()) assert.equal(result.success, true) assert.equal(result.clientSecret, 'super-secret') }) @@ -288,18 +287,12 @@ describe('profile defaults', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(webappOptions()) assert.equal(result.success, true) }) test('optional profile options are included when provided', async () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + mockCompliantSystemConfig() nock(FA_HOST) .post('/api/application/', (body) => { @@ -310,13 +303,10 @@ describe('profile defaults', () => { }) .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], + const result = await executeApplicationCreate(spaOptions({ authorizedOriginUrl: ['https://example.com'], logoutUrl: 'https://example.com/logout', - }) + })) assert.equal(result.success, true) }) }) @@ -327,9 +317,7 @@ describe('profile defaults', () => { describe('ID overrides', () => { test('--application-id is sent in the request URL and body', async () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + mockCompliantSystemConfig() nock(FA_HOST) .post(`/api/application/${APP_ID}`, (body) => { @@ -338,12 +326,7 @@ describe('ID overrides', () => { }) .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - applicationId: APP_ID, - }) + const result = await executeApplicationCreate(spaOptions({ applicationId: APP_ID })) assert.equal(result.success, true) assert.equal(result.applicationId, APP_ID) }) @@ -402,19 +385,12 @@ describe('regression: tenant header scoping', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - tenantId: TENANT_ID, - }) + 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 () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + mockCompliantSystemConfig() nock(FA_HOST, { reqheaders: { 'x-fusionauth-tenantid': TENANT_ID }, @@ -422,12 +398,7 @@ describe('regression: tenant header scoping', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - tenantId: TENANT_ID, - }) + const result = await executeApplicationCreate(spaOptions({ tenantId: TENANT_ID })) assert.equal(result.success, true) }) }) @@ -447,11 +418,7 @@ describe('regression: error attribution', () => { // 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({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + 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. @@ -468,12 +435,7 @@ describe('regression: error attribution', () => { .patch('/api/system-configuration') .reply(403) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - yes: true, - }) + 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/) @@ -485,11 +447,7 @@ describe('regression: error attribution', () => { .post('/api/application/') .reply(500, {}) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(webappOptions()) assert.equal(result.success, false) assert.match(result.error, /Error creating application/) }) @@ -499,11 +457,7 @@ describe('regression: error attribution', () => { .post('/api/application/') .reply(400, { fieldErrors: { name: [{ message: 'is required' }] } }) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - }) + 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), @@ -531,11 +485,7 @@ describe('CORS header management', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(spaOptions()) assert.equal(result.success, true) }) @@ -562,12 +512,7 @@ describe('CORS header management', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - yes: true, - }) + const result = await executeApplicationCreate(spaOptions({ yes: true })) assert.equal(result.success, true) }) @@ -590,12 +535,7 @@ describe('CORS header management', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - yes: true, - }) + const result = await executeApplicationCreate(spaOptions({ yes: true })) assert.equal(result.success, true) }) }) @@ -621,11 +561,7 @@ describe('confirmation gate for CORS mutation', () => { // Deliberately no PATCH or POST /api/application interceptors registered — // if either were called, afterEach's nock.isDone() check would fail. - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(spaOptions()) assert.equal(result.success, false) assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') @@ -645,12 +581,7 @@ describe('confirmation gate for CORS mutation', () => { .post('/api/application/') .reply(200, APP_RESPONSE) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - yes: true, - }) + const result = await executeApplicationCreate(spaOptions({ yes: true })) assert.equal(result.success, true) }) @@ -666,48 +597,21 @@ describe('output', () => { .post('/api/application/') .reply(200, APP_RESPONSE_WITH_SECRET) - const result = await executeApplicationCreate({ - ...BASE_OPTIONS, - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(webappOptions()) assert.equal(result.success, true) assert.equal(result.clientSecret, 'super-secret') }) - test('clientSecret is absent for spa profile (public client)', async () => { - nock(FA_HOST) - .get('/api/system-configuration') - .reply(200, systemConfigResponse()) + 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({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) + const result = await executeApplicationCreate(spaOptions()) assert.equal(result.success, true) assert.equal(result.clientSecret, undefined) - }) - - test('result contains name, applicationId, and clientId', 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({ - ...BASE_OPTIONS, - profile: 'spa', - redirectUri: ['https://example.com/callback'], - }) - assert.equal(result.success, true) assert.equal(result.name, 'Test App') assert.equal(result.applicationId, APP_ID) assert.equal(result.clientId, APP_ID) From a97c7633ec00dc6319e5438b84c7cb67f3527f47 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:05:25 -0600 Subject: [PATCH 22/39] test: reduce duplication in application-create.integration.test.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../application-create.integration.test.js | 71 ++++++++----------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index a723014..b3fceb4 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -13,6 +13,7 @@ import { executeApplicationCreate } from '../../../src/commands/application-crea const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept'] +const REDIRECT_URI = 'https://example.com/callback' describe('application:create integration tests', () => { let fusionAuthUrl @@ -53,15 +54,32 @@ describe('application:create integration tests', () => { } } + function spaOptions(overrides = {}) { + return baseOptions({ profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides }) + } + + function webappOptions(overrides = {}) { + return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) + } + + // Verifies the DPoP-related CORS headers required by spa/native profiles + // 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(baseOptions({ - profile: 'spa', - redirectUri: ['https://example.com/callback'], - })) + const result = await executeApplicationCreate(spaOptions()) assert.equal(result.success, true, `Expected success but got: ${result.error}`) assert.ok(result.applicationId, 'applicationId should be set') @@ -80,19 +98,13 @@ describe('application:create integration tests', () => { 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, ['https://example.com/callback']) + 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 - 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) + await assertCorsHeadersConfigured(apiKey) }) test('--profile native creates application with correct settings and configures CORS', async () => { @@ -115,19 +127,11 @@ describe('application:create integration tests', () => { assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) // CORS must also be configured for native - 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}'`) - } + await assertCorsHeadersConfigured(apiKey) }) test('--profile webapp creates confidential client and returns clientSecret', async () => { - const result = await executeApplicationCreate(baseOptions({ - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - })) + 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') @@ -178,18 +182,12 @@ describe('application:create integration tests', () => { test('running spa create twice does not duplicate CORS headers', async () => { // First create - const result1 = await executeApplicationCreate(baseOptions({ - profile: 'spa', - redirectUri: ['https://example.com/callback'], - })) + const result1 = await executeApplicationCreate(spaOptions()) assert.equal(result1.success, true) createdApplicationIds.push(result1.applicationId) // Second create without resetting CORS - const result2 = await executeApplicationCreate(baseOptions({ - profile: 'spa', - redirectUri: ['https://example.com/callback2'], - })) + const result2 = await executeApplicationCreate(spaOptions({ redirectUri: ['https://example.com/callback2'] })) assert.equal(result2.success, true) createdApplicationIds.push(result2.applicationId) @@ -213,11 +211,8 @@ describe('application:create integration tests', () => { 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. - const result = await executeApplicationCreate(baseOptions({ - profile: 'spa', - redirectUri: ['https://example.com/callback'], - tenantId: TENANT_ID, - })) + // (--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) }) @@ -228,11 +223,7 @@ describe('application:create integration tests', () => { test('--application-id is respected and application is created with that ID', async () => { const customId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' - const result = await executeApplicationCreate(baseOptions({ - profile: 'webapp', - redirectUri: ['https://example.com/callback'], - applicationId: customId, - })) + const result = await executeApplicationCreate(webappOptions({ applicationId: customId })) assert.equal(result.success, true, `Expected success but got: ${result.error}`) assert.equal(result.applicationId, customId) From bda16d65c3c75d028a40db1de37b4864cb133eb8 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:32:41 -0600 Subject: [PATCH 23/39] fix: prevent leaked FusionAuth integration test containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../docker-compose.yml | 3 - __tests__/integration/setup.js | 82 +++++++++++++++---- 2 files changed, 67 insertions(+), 18 deletions(-) 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 c4bec2c..f1d78c2 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -22,10 +22,52 @@ const HEALTH_CHECK_TIMEOUT = 240000 // 4 minutes const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const REQUEST_TIMEOUT = 10000 // 10 seconds const CONTAINER_NAME = 'fusionauth-integration-test-base-fusionauth-1' +const COMPOSE_DIR = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname 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(`cd ${COMPOSE_DIR} && docker compose down -v`) + 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) return + 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 URL. On environments where localhost port-mapping * behaves differently (e.g. macOS Docker Desktop), falls back to the @@ -86,9 +128,8 @@ export async function startFusionAuthContainer() { 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 = ` @@ -107,20 +148,33 @@ 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(`cd ${COMPOSE_DIR} && docker compose ps -aq`) + 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(`cd ${COMPOSE_DIR} && docker compose down -v`) + console.log('✓ Existing containers removed') } // Start containers - await execAsync(`cd ${composeDir} && docker compose --env-file .env.test up -d`) + await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test up -d`) // Wait for FusionAuth to be healthy await waitForFusionAuthReady() @@ -154,10 +208,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(`cd ${COMPOSE_DIR} && docker compose down -v`) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { From fd5d788bc82dc025a2325baa977308bf3e20a397 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:34:30 -0600 Subject: [PATCH 24/39] build: use glob patterns instead of per-file lists in test scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8da15a6..55bf8b5 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/commands/application-create.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/application-create/application-create.integration.test.js", - "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js && NODE_ENV=test node --import=tsx --test __tests__/integration/application-create/application-create.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/commands/application-create.test.js", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "NODE_ENV=test node --import=tsx --test --test-concurrency=1 '__tests__/integration/**/*.test.js'", + "test:unit": "NODE_ENV=test 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": [ From bc15a10a6e8d9a0972f79ba1d362ee6660f209bf Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:49:25 -0600 Subject: [PATCH 25/39] fix: add --authorized-origin-url to the system CORS allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/commands/application-create.test.js | 95 ++++++++++++++++++- .../application-create.integration.test.js | 22 +++++ src/commands/application-create.ts | 38 ++++++-- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index cc6c3d1..1fb42d6 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -292,7 +292,12 @@ describe('profile defaults', () => { }) test('optional profile options are included when provided', async () => { - mockCompliantSystemConfig() + // 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) => { @@ -538,6 +543,77 @@ describe('CORS header management', () => { 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('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) + }) }) // --------------------------------------------------------------------------- @@ -585,6 +661,23 @@ describe('confirmation gate for CORS mutation', () => { 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') + }) }) // --------------------------------------------------------------------------- diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index b3fceb4..2549614 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -107,6 +107,28 @@ describe('application:create integration tests', () => { 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 with correct settings and configures CORS', async () => { const result = await executeApplicationCreate(baseOptions({ profile: 'native', diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 6279c97..42920b3 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -123,11 +123,20 @@ function unwrapError(e: unknown): unknown { } /** - * Ensures that the required DPoP-related CORS headers are present in the + * Ensures that the required DPoP-related 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 spa and native profiles 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 a spa/native 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 is a prerequisite for spa/native DPoP flows. If this call fails the * entire command is aborted — no application will be created. * @@ -137,7 +146,7 @@ function unwrapError(e: unknown): unknown { * 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): Promise { +async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean, authorizedOrigins: string[]): Promise { const originalTenantId = client.tenantId ?? null; client.setTenantId(null); @@ -158,15 +167,23 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promis (h) => !existingLower.includes(h.toLowerCase()) ); + // Origins are case-sensitive, unlike header names, and "*" already + // permits every origin — nothing to add in that case. + const existingOrigins: string[] = cors.allowedOrigins ?? []; + const missingOrigins = existingOrigins.includes('*') + ? [] + : authorizedOrigins.filter((o) => !existingOrigins.includes(o)); + const needsEnable = cors.enabled !== true; - if (missing.length === 0 && !needsEnable) { + 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( @@ -182,6 +199,9 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promis ...cors, enabled: true, allowedHeaders: [...existing, ...missing], + ...(missingOrigins.length > 0 + ? {allowedOrigins: [...existingOrigins, ...missingOrigins]} + : {}), }, }, }); @@ -189,6 +209,9 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean): Promis 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 instanceof Error ? e.message : String(e)}`, e); } @@ -296,11 +319,12 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions const fusionAuthClient = new FusionAuthClient(apiKey, host, tenantId); - // For spa/native profiles, enforce DPoP-required CORS headers first. - // If this fails (or the user declines the confirmation prompt), the - // command aborts — createApplication is never called. + // For spa/native profiles, 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. if (profile === 'spa' || profile === 'native') { - await ensureCorsHeaders(fusionAuthClient, yes ?? false); + await ensureCorsHeaders(fusionAuthClient, yes ?? false, authorizedOriginUrl ?? []); } let clientResponse; From 7bb5071fc2b81a0048b7caa4b17bfe975f268cc6 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:11:53 -0600 Subject: [PATCH 26/39] 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 --- AGENTS.md | 2 +- __tests__/commands/application-create.test.js | 12 ++++++++++++ __tests__/telemetry/telemetry.test.js | 8 ++++++++ package.json | 4 ++-- src/commands/application-create.ts | 18 ++++++++++++++++-- 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61230a0..3b6b715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ FusionAuth CLI is a command-line tool for working with the FusionAuth CIAM platf ## 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 diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 1fb42d6..51fd244 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -117,6 +117,18 @@ describe('mode validation', () => { 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/) + }) }) // --------------------------------------------------------------------------- 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/package.json b/package.json index 55bf8b5..39d653b 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", "test": "npm run test:unit && npm run test:integration", - "test:integration": "NODE_ENV=test node --import=tsx --test --test-concurrency=1 '__tests__/integration/**/*.test.js'", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js '__tests__/postInstall/*.test.js' '__tests__/telemetry/*.test.js' '__tests__/utilities/**/*.test.js' '__tests__/commands/*.test.js'", + "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 index 42920b3..45af5f2 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -247,8 +247,19 @@ function parseData(data: string): Application { } /** - * Core logic for application:create. Returns a result object rather than - * calling process.exit(), allowing tests to import and invoke this directly. + * 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 spa/native profiles, 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 { @@ -286,6 +297,9 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions if (!name) { return { success: false, error: '--name is required when using --profile.' }; } + if (!(profile in profileDefaults)) { + return { success: false, error: `--profile must be one of: ${Object.keys(profileDefaults).join(', ')}.` }; + } const defaults = profileDefaults[profile as Profile]; application = {...defaults}; From 18258da98e9bbe32c46aae3635217b54330606df Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:40:34 -0600 Subject: [PATCH 27/39] 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. --- __tests__/commands/application-create.test.js | 87 +++++++++++++++++++ src/commands/application-create.ts | 31 ++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 51fd244..18ff24c 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -216,6 +216,93 @@ describe('--data parsing', () => { }) 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/native)', 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) + }) }) // --------------------------------------------------------------------------- diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 45af5f2..f634501 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -314,13 +314,38 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions }; } else { // --- Custom mode --- - // --name is optional here so --data can provide "full custom control": - // only override the JSON's name field if --name was explicitly passed. - // If neither supplies a name, FusionAuth's API will reject the request. + // --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) --- From 567a521bb281676aa8b1e60df7c3f44de68a485b Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:14:45 -0600 Subject: [PATCH 28/39] 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. --- __tests__/integration/setup.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index f1d78c2..ee0bcd7 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -41,7 +41,7 @@ async function forceTeardown(reason) { return } try { - await execAsync(`cd ${COMPOSE_DIR} && docker compose down -v`) + await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) isContainerRunning = false } catch (err) { console.error(`Warning: Failed to stop container during ${reason} cleanup: ${err.message}`) @@ -156,7 +156,7 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m // "Conflict: container name already in use". let psOutput = '' try { - const result = await execAsync(`cd ${COMPOSE_DIR} && docker compose ps -aq`) + const result = await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test ps -aq`) psOutput = result.stdout } catch (e) { // `docker compose ps` itself failing (e.g. project has never existed) @@ -169,7 +169,7 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m // 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(`cd ${COMPOSE_DIR} && docker compose down -v`) + await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) console.log('✓ Existing containers removed') } @@ -209,7 +209,7 @@ export async function stopFusionAuthContainer() { console.log('↻ Stopping FusionAuth container...') try { - await execAsync(`cd ${COMPOSE_DIR} && docker compose down -v`) + await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { From 8bf1a6b2991a174690fb24d91b7300b068c59390 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:47:51 -0600 Subject: [PATCH 29/39] fix: profile validation accepted inherited Object properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- README.md | 2 +- __tests__/commands/application-create.test.js | 12 ++++++++++++ src/commands/application-create.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fb0071..ea7842f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Currently, the CLI supports the following commands: - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Applications - - `fusionauth application:create --name --profile --redirect-uri ` - 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, for spa/native). + - `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). `--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. diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 18ff24c..5029feb 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -129,6 +129,18 @@ describe('mode validation', () => { 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/) + }) }) // --------------------------------------------------------------------------- diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index f634501..7cf22b3 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -297,7 +297,7 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions if (!name) { return { success: false, error: '--name is required when using --profile.' }; } - if (!(profile in profileDefaults)) { + if (!Object.keys(profileDefaults).includes(profile)) { return { success: false, error: `--profile must be one of: ${Object.keys(profileDefaults).join(', ')}.` }; } From 28acb39ca59586f0c3fb8dfabc5b8b569fbc6289 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:30:53 -0600 Subject: [PATCH 30/39] fix: restrict automatic CORS configuration to --profile spa only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- __tests__/commands/application-create.test.js | 23 ++++++++++--- .../application-create.integration.test.js | 16 +++++---- __tests__/integration/setup.js | 2 +- src/commands/application-create.ts | 33 +++++++++++-------- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ea7842f..53ba95f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Currently, the CLI supports the following commands: - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Applications - - `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). `--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 --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. diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 5029feb..5a84f41 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -301,7 +301,7 @@ describe('--data parsing', () => { assert.equal(result.success, true) }) - test('--data mode does not mutate system CORS configuration (unlike --profile spa/native)', async () => { + 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) @@ -348,9 +348,7 @@ describe('profile defaults', () => { assert.equal(result.success, true) }) - test('native profile sends same defaults as spa', async () => { - mockCompliantSystemConfig() - + test('native profile sends same oauth defaults as spa', async () => { nock(FA_HOST) .post('/api/application/', (body) => { const oauth = body.application.oauthConfiguration @@ -369,6 +367,23 @@ describe('profile defaults', () => { 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) => { diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index 2549614..f1f67a8 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -18,13 +18,14 @@ 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 - await captureSystemConfigurationBaseline(apiKey) + systemConfigBaseline = await captureSystemConfigurationBaseline(apiKey) }) after(async () => { @@ -46,7 +47,7 @@ describe('application:create integration tests', () => { key: apiKey, host: fusionAuthUrl, tenantId: TENANT_ID, - // Bypasses the CORS-change confirmation prompt (spa/native profiles). + // 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, @@ -62,7 +63,7 @@ describe('application:create integration tests', () => { return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) } - // Verifies the DPoP-related CORS headers required by spa/native profiles + // Verifies the DPoP-related CORS headers required by 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) @@ -129,7 +130,7 @@ describe('application:create integration tests', () => { assert(allowedOrigins.includes(origin), `system CORS allowedOrigins should contain '${origin}'`) }) - test('--profile native creates application with correct settings and configures CORS', async () => { + test('--profile native creates application without touching system CORS configuration', async () => { const result = await executeApplicationCreate(baseOptions({ profile: 'native', redirectUri: ['myapp://callback'], @@ -148,8 +149,11 @@ describe('application:create integration tests', () => { assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['myapp://callback']) assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) - // CORS must also be configured for native - await assertCorsHeadersConfigured(apiKey) + // 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 () => { diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index ee0bcd7..649623d 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -433,7 +433,7 @@ 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/native). + * modifies system configuration (e.g. application:create --profile spa). * @param {string} apiKey - API key * @returns {Promise} */ diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 7cf22b3..d028205 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -42,9 +42,11 @@ export interface ApplicationCreateResult { name?: string; } -// Shared refresh token policy for all profiles. A sliding window of -// one-time-use refresh tokens is the recommended default across -// spa/native/webapp — only timeToLiveInSeconds differs per profile. +// 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, @@ -126,19 +128,22 @@ function unwrapError(e: unknown): unknown { * Ensures that the required DPoP-related 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 spa and native profiles before creating - * the application. + * 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 a spa/native app's own origin unless it's present there (or + * 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 is a prerequisite for spa/native DPoP flows. If this call fails the - * entire command is aborted — no application will be created. + * 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. @@ -252,7 +257,7 @@ function parseData(data: string): Application { * process.exit() directly — this is what allows tests to import and invoke * it, and non-CLI callers to handle failures programmatically. * - * One exception: for spa/native profiles, this calls ensureCorsHeaders(), + * 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 @@ -358,11 +363,13 @@ export async function executeApplicationCreate(options: ApplicationCreateOptions const fusionAuthClient = new FusionAuthClient(apiKey, host, tenantId); - // For spa/native profiles, enforce DPoP-required CORS headers (and + // 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. - if (profile === 'spa' || profile === 'native') { + // 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 ?? []); } @@ -452,7 +459,7 @@ export const applicationCreate = new Command('application:create') .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/native profiles)', false) + .option('--yes', 'Skip confirmation prompt for automatic CORS configuration changes (spa profile)', false) .addOption(apiKeyOption) .addOption(hostOption) .action(action); From 52ae42e297ffad47cbd70f6a25fb0f9f88abf356 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:56:55 -0600 Subject: [PATCH 31/39] 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. --- __tests__/commands/kickstart-install.test.js | 24 ++++++++++++++++ src/commands/kickstart-install.ts | 29 ++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index d59a65d..962d2c9 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -1,9 +1,11 @@ import { describe, test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' import { validateEmail, validatePassword, resolveInstallAnswers, + resolveResourcesDir, } from '../../src/commands/kickstart-install.js' // --------------------------------------------------------------------------- @@ -340,3 +342,25 @@ describe('resolveInstallAnswers() — inquirer validate functions', () => { assert.equal(passwordQuestion.validate, validatePassword) }) }) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() +// --------------------------------------------------------------------------- + +describe('resolveResourcesDir()', () => { + test('resolves to an existing directory containing kickstart resources', () => { + // Covers both layouts this file can run from: dist/commands/resources + // (built, via copy-files) and src/resources (running the TS source + // directly, e.g. `npm start`, before any build has copied anything). + const resourcesDir = resolveResourcesDir() + assert.ok(fs.existsSync(resourcesDir), `${resourcesDir} should exist`) + assert.ok( + fs.existsSync(`${resourcesDir}/kickstart/fusionauth`), + `${resourcesDir}/kickstart/fusionauth should exist` + ) + assert.ok( + fs.existsSync(`${resourcesDir}/kickstart/kickstart.json`), + `${resourcesDir}/kickstart/kickstart.json should exist` + ) + }) +}) diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 88595c1..f8423aa 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -14,6 +14,30 @@ 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. `npm start` running this file directly via tsx): + * 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. + */ +export function resolveResourcesDir(): string { + const distLayout = path.join(__dirname, 'resources'); + if (fs.existsSync(distLayout)) { + return distLayout; + } + const srcLayout = path.join(__dirname, '..', '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) // --------------------------------------------------------------------------- @@ -210,12 +234,13 @@ const action = async function (dir: string, options: InstallOptions) { // 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(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) + 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(__dirname + '/resources/kickstart/kickstart.json', answers, directory) + await createKickstart(resourcesDir + '/kickstart/kickstart.json', answers, directory) const postgresPass = randomUUID() const dbPass = randomUUID() From bd81c9359ea44a297b79bbc9adcfa0b631c70130 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:58:20 -0600 Subject: [PATCH 32/39] fix: restore build-first npm start script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39d653b..4b3dbad 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "copy-files": "cp -r ./src/resources/ ./dist/commands/resources/", "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", - "start": "node --import=tsx src/index.ts", + "start": "npm run build && node dist/index.js", "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'", From a648f1c2e96c505ff75d59c7c1c1a3e7173fec85 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:54:16 -0600 Subject: [PATCH 33/39] fix: validate --data JSON is a non-null, non-array object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- __tests__/commands/application-create.test.js | 38 ++++++++++++++++++- __tests__/commands/kickstart-install.test.js | 2 +- src/commands/application-create.ts | 16 +++++++- src/commands/kickstart-install.ts | 7 ++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 5a84f41..9e57e2c 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -188,6 +188,36 @@ describe('--data parsing', () => { 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, @@ -752,8 +782,12 @@ 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, - // per its documented contract of never exiting the process itself. + // 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) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index 962d2c9..5c3da55 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -351,7 +351,7 @@ describe('resolveResourcesDir()', () => { test('resolves to an existing directory containing kickstart resources', () => { // Covers both layouts this file can run from: dist/commands/resources // (built, via copy-files) and src/resources (running the TS source - // directly, e.g. `npm start`, before any build has copied anything). + // directly via tsx, before any build has copied anything). const resourcesDir = resolveResourcesDir() assert.ok(fs.existsSync(resourcesDir), `${resourcesDir} should exist`) assert.ok( diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index d028205..1cb4378 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -228,7 +228,12 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean, authori /** * Parses the --data value. If it begins with '@', reads the referenced file. * Otherwise parses the value as inline JSON. - * Throws an Error on parse or file-read failure (caught by executeApplicationCreate). + * 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; @@ -243,12 +248,19 @@ function parseData(data: string): Application { } else { json = data; } + let parsed: unknown; try { - return JSON.parse(json) as Application; + 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; } /** diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index f8423aa..fa4a7a9 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -20,9 +20,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); * 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. `npm start` running this file directly via tsx): - * resources live one level up, at src/resources — they are not copied - * anywhere until a build runs. + * - 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. */ From 2e4ae5885bb7e3bede528c520abfdc91343194f2 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:19:37 -0600 Subject: [PATCH 34/39] 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. --- src/commands/application-create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 1cb4378..590bf67 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -467,7 +467,7 @@ export const applicationCreate = new Command('application:create') ) .option('--redirect-uri ', 'Authorized redirect URIs (required with --profile)') .option('--logout-url ', 'Post-logout redirect URL') - .option('--authorized-origin-url ', 'Authorized origin URLs (for CORS)') + .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)') From ef9c0fa545b16bd43ad1013c43d4f63a767fef60 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:51:16 -0600 Subject: [PATCH 35/39] 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. --- __tests__/commands/application-create.test.js | 17 +++++++++-- .../application-create.integration.test.js | 6 ++-- src/commands/application-create.ts | 29 ++++++++++++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index 9e57e2c..fecb1bc 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -55,7 +55,7 @@ function systemConfigResponse(overrides = {}) { systemConfiguration: { corsConfiguration: { enabled: true, - allowedHeaders: ['dpop', 'Authorization', 'Accept'], + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], ...overrides, }, }, @@ -627,6 +627,19 @@ describe('regression: error attribution', () => { assert.equal(result.rawError.statusCode, 400) assert.deepEqual(result.rawError.exception, { fieldErrors: { name: [{ message: 'is required' }] } }) }) + + 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) + }) }) // --------------------------------------------------------------------------- @@ -682,7 +695,7 @@ describe('CORS header management', () => { .get('/api/system-configuration') .reply(200, systemConfigResponse({ enabled: false, - allowedHeaders: ['dpop', 'Authorization', 'Accept'], + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], })) nock(FA_HOST) diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js index f1f67a8..8527a64 100644 --- a/__tests__/integration/application-create/application-create.integration.test.js +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -12,7 +12,7 @@ import { import { executeApplicationCreate } from '../../../src/commands/application-create.js' const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' -const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept'] +const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept', 'content-type'] const REDIRECT_URI = 'https://example.com/callback' describe('application:create integration tests', () => { @@ -63,8 +63,8 @@ describe('application:create integration tests', () => { return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) } - // Verifies the DPoP-related CORS headers required by the spa profile - // were added to system configuration, and that CORS is enabled. + // 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 ?? []) diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index 590bf67..f0da2ae 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -97,7 +97,13 @@ const profileDefaults: Record = { }, }; -const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept']; +// 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 @@ -114,19 +120,28 @@ function wrapError(message: string, cause: unknown): Error { /** * Unwraps an error produced by wrapError() back to its original cause, for - * use as ApplicationCreateResult.rawError. Falls back to the error itself - * when there's no cause (e.g. errors that were never wrapped). + * 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 && 'cause' in e && e.cause !== undefined) { - return e.cause; + if (e instanceof Error) { + return 'cause' in e && e.cause !== undefined ? e.cause : undefined; } return e; } /** - * Ensures that the required DPoP-related CORS headers — and, when - * authorizedOrigins is non-empty, those origins — are present in the + * 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. From 1bd1254153e43fd6196b6a72d166b380a2858890 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:03:05 -0600 Subject: [PATCH 36/39] 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. --- __tests__/commands/kickstart-install.test.js | 90 ++++++++++++++++++-- src/commands/kickstart-install.ts | 13 ++- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index 5c3da55..459a435 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -1,6 +1,9 @@ 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, @@ -346,20 +349,93 @@ describe('resolveInstallAnswers() — inquirer validate functions', () => { // --------------------------------------------------------------------------- // 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()', () => { - test('resolves to an existing directory containing kickstart resources', () => { - // Covers both layouts this file can run from: dist/commands/resources - // (built, via copy-files) and src/resources (running the TS source - // directly via tsx, before any build has copied anything). - const resourcesDir = resolveResourcesDir() + function mkTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + } + + 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(`${resourcesDir}/kickstart/fusionauth`), + fs.existsSync(path.join(resourcesDir, 'kickstart', 'fusionauth')), `${resourcesDir}/kickstart/fusionauth should exist` ) assert.ok( - fs.existsSync(`${resourcesDir}/kickstart/kickstart.json`), + fs.existsSync(path.join(resourcesDir, 'kickstart', 'kickstart.json')), `${resourcesDir}/kickstart/kickstart.json should exist` ) }) diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index fa4a7a9..62004b2 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -26,13 +26,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); * 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(): string { - const distLayout = path.join(__dirname, 'resources'); +export function resolveResourcesDir(baseDir: string = __dirname): string { + const distLayout = path.join(baseDir, 'resources'); if (fs.existsSync(distLayout)) { return distLayout; } - const srcLayout = path.join(__dirname, '..', 'resources'); + const srcLayout = path.join(baseDir, '..', 'resources'); if (fs.existsSync(srcLayout)) { return srcLayout; } From da3659df679852fb6750181d3cd4437607679090 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:55:20 -0600 Subject: [PATCH 37/39] 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. --- __tests__/integration/setup.js | 47 +++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index 649623d..e1c74d3 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 @@ -21,8 +22,12 @@ const DEFAULT_API_KEY = '90dd6b25-d1ef-4175-9656-159dd994932e' const HEALTH_CHECK_TIMEOUT = 240000 // 4 minutes const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const REQUEST_TIMEOUT = 10000 // 10 seconds -const CONTAINER_NAME = 'fusionauth-integration-test-base-fusionauth-1' -const COMPOSE_DIR = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname +// 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 @@ -41,7 +46,7 @@ async function forceTeardown(reason) { return } try { - await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) + 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}`) @@ -68,6 +73,26 @@ async function handleTerminationSignal(signal) { 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 @@ -90,8 +115,10 @@ async function resolveFusionAuthUrl() { // 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 ${CONTAINER_NAME} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` ) const ips = stdout.trim().split(/\s+/).filter(Boolean) for (const ip of ips) { @@ -156,7 +183,7 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m // "Conflict: container name already in use". let psOutput = '' try { - const result = await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test ps -aq`) + const result = await execAsync('docker compose --env-file .env.test ps -aq', { cwd: COMPOSE_DIR }) psOutput = result.stdout } catch (e) { // `docker compose ps` itself failing (e.g. project has never existed) @@ -169,12 +196,12 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m // 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(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) console.log('✓ Existing containers removed') } // Start containers - await execAsync(`cd ${COMPOSE_DIR} && 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() @@ -209,7 +236,7 @@ export async function stopFusionAuthContainer() { console.log('↻ Stopping FusionAuth container...') try { - await execAsync(`cd ${COMPOSE_DIR} && docker compose --env-file .env.test down -v`) + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { @@ -250,8 +277,10 @@ async function waitForFusionAuthReady() { // overridden. const urlsToTry = [DEFAULT_FUSIONAUTH_URL] try { + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') const { stdout } = await execAsync( - `docker inspect ${CONTAINER_NAME} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + `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`) From d19dfc9848b0df0cbd2c61c014b19191ec8bdb9c Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:15:10 -0600 Subject: [PATCH 38/39] 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. --- __tests__/commands/application-create.test.js | 26 +++++++++++++++++++ __tests__/commands/kickstart-install.test.js | 12 ++++++++- src/commands/application-create.ts | 10 +++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js index fecb1bc..3af3b50 100644 --- a/__tests__/commands/application-create.test.js +++ b/__tests__/commands/application-create.test.js @@ -738,6 +738,32 @@ describe('CORS header management', () => { 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" diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index 459a435..ea24a7f 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -358,10 +358,20 @@ describe('resolveInstallAnswers() — inquirer validate functions', () => { // tsx, fixing __dirname to .../src/commands for the whole test run. describe('resolveResourcesDir()', () => { + const createdDirs = [] + function mkTempDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), 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') diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts index f0da2ae..b47b266 100644 --- a/src/commands/application-create.ts +++ b/src/commands/application-create.ts @@ -188,11 +188,17 @@ async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean, authori ); // Origins are case-sensitive, unlike header names, and "*" already - // permits every origin — nothing to add in that case. + // 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('*') ? [] - : authorizedOrigins.filter((o) => !existingOrigins.includes(o)); + : dedupedAuthorizedOrigins.filter((o) => !existingOrigins.includes(o)); const needsEnable = cors.enabled !== true; From 788aa009a112f877c4e394ebd5b6c6615be6e179 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:51:34 -0600 Subject: [PATCH 39/39] 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. --- __tests__/integration/setup.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index e1c74d3..7059e6b 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -63,7 +63,15 @@ let handlingTerminationSignal = false * @param {string} signal */ async function handleTerminationSignal(signal) { - if (handlingTerminationSignal) return + 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)