diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 44a5aad..f6c91bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,9 @@ on: push: pull_request: workflow_dispatch: + pull_request: + branches: + - main jobs: test: diff --git a/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json b/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json new file mode 100644 index 0000000..1212596 --- /dev/null +++ b/3c219e58-ed0e-4b18-ad48-f4f92793ae32.json @@ -0,0 +1,9 @@ +{ + "application": { + "id": "3c219e58-ed0e-4b18-ad48-f4f92793ae32", + "name": "Test App", + "oauthConfiguration": { + "clientId": "3c219e58-ed0e-4b18-ad48-f4f92793ae32" + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1e9e6f5..3b6b715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,12 @@ -# FusionAuth CLI - Agent Guidelines +# FusionAuth CLI +FusionAuth CLI is a command-line tool for working with the FusionAuth CIAM platform. + +# Guidelines ## Build Commands - Build: `npm run build` (compiles TypeScript to `./dist/`) - No lint command configured -- No test framework - tests not implemented +- Tests use Node's built-in test runner (`node:test`); run `npm run test:unit` for fast unit tests or `npm test` for the full suite, including Docker-based integration tests under `__tests__/integration/`. ## Code Style Guidelines @@ -27,8 +30,14 @@ - Custom error reporting via `utils.reportError()` and `utils.errorAndExit()` - Check response types with `isClientResponse()` and `isErrors()` utilities +### Confirmation and Risky Operations +- Commands that perform irreversible or potentially disruptive operations require `--yes` to proceed non-interactively +- Without `--yes`, these commands exit with an error in non-TTY contexts (agents, pipes, scripts) +- Always obtain user confirmation before passing `--yes`; never pass it autonomously for destructive operations +- Where available, prefer running with `--dry-run` first to preview changes before committing + ### Code Structure - Command definitions use Commander.js with fluent API - JSDoc comments for function documentation - Async/await for asynchronous operations -- Template literals for string interpolation \ No newline at end of file +- Template literals for string interpolation diff --git a/CHANGELOG.md b/CHANGELOG.md index 68575f5..363004e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,23 @@ -## [1.9.1-next-setup.3](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.1-next-setup.2...v1.9.1-next-setup.3) (2026-07-13) +## [1.9.1-next.1](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.0...v1.9.1-next.1) (2026-07-09) ### Bug Fixes -* merges current into old branch ([58fd424](https://github.com/FusionAuth/fusionauth-node-cli/commit/58fd42463995387711e3838e7722fca1443996da)) +* **release:** adds back github token ([cd34c52](https://github.com/FusionAuth/fusionauth-node-cli/commit/cd34c52382950ebea18dc6a6e07f68fb8b5e0697)) +* **release:** adds defaults ([fdac965](https://github.com/FusionAuth/fusionauth-node-cli/commit/fdac965d591b21a657fb105285b6c0898322c387)) +* **release:** adds dependency of test job ([3cd41cf](https://github.com/FusionAuth/fusionauth-node-cli/commit/3cd41cf2465965658abb6d07ce5e116da402fedd)) +* **release:** adds new release workflows ([9350a16](https://github.com/FusionAuth/fusionauth-node-cli/commit/9350a169050d2385c4b4f0cfd76473894ee396d0)) +* **release:** adds permissions to action ([d66921b](https://github.com/FusionAuth/fusionauth-node-cli/commit/d66921ba6c2289bdeacb9388c2d1b48ec474845d)) +* **release:** brings testing job into release action ([94551bd](https://github.com/FusionAuth/fusionauth-node-cli/commit/94551bdce7c7baafaae59c615b8e375329b122f5)) +* **release:** fixes commitlint issues ([ac51e58](https://github.com/FusionAuth/fusionauth-node-cli/commit/ac51e588f5c879c2580f1fa0ade6c7ccfed20884)) +* **release:** fixes typo in workflow ([53d1443](https://github.com/FusionAuth/fusionauth-node-cli/commit/53d1443b34cd24e177f9fd8964741519a6352de8)) +* **release:** makes release config common js ([ccefcad](https://github.com/FusionAuth/fusionauth-node-cli/commit/ccefcad70568436994732baedbb21d4c4fabeb5d)) +* **release:** removes extraneous test that was causing failure in build not local ([687bbd6](https://github.com/FusionAuth/fusionauth-node-cli/commit/687bbd6e766909aae65f5f50a550449c514f0c58)) +* **release:** removes PR action and corrects branch for testing push ([5a1059a](https://github.com/FusionAuth/fusionauth-node-cli/commit/5a1059a0c36313b2ba5c713288735f89973faee6)) +* **release:** replaces inline testing with test workflow chaining ([d5b77b7](https://github.com/FusionAuth/fusionauth-node-cli/commit/d5b77b7326925295e50ac3606194033c27e70e76)) +* **release:** stop committing build output and drop unused deps ([d64ac2c](https://github.com/FusionAuth/fusionauth-node-cli/commit/d64ac2c3b37c3a260e3f9e5f31464a2b873a8bfd)), closes [#45](https://github.com/FusionAuth/fusionauth-node-cli/issues/45) +* **release:** updates branches for workflow for main and next ([725d29c](https://github.com/FusionAuth/fusionauth-node-cli/commit/725d29c4216a3729c0d010cdcebf3b73aae508b9)) +* **release:** updates workflow away from quotations as per the github docs ([c6dbd1a](https://github.com/FusionAuth/fusionauth-node-cli/commit/c6dbd1a53ac9ad1d22cd11256446c9893a8ea132)) ## [1.9.1-next-setup.2](https://github.com/FusionAuth/fusionauth-node-cli/compare/v1.9.1-next-setup.1...v1.9.1-next-setup.2) (2026-07-09) diff --git a/README.md b/README.md index 17e2de9..44db75f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,18 @@ fusionauth --help; ``` Currently, the CLI supports the following commands: +- Application management + - `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. + - `fusionauth application:update ` - Updates an application with provided data + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update + - `-p, --prop ` - Update a single property in the application + - `fusionauth application:get ` - Get a JSON representation of an application's settings + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-o, --output` - Optional. Writes the JSON object to the indicated file - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - OAuth 2.1 compliance check diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js new file mode 100644 index 0000000..52ae641 --- /dev/null +++ b/__tests__/commands/application-create.test.js @@ -0,0 +1,933 @@ +import { describe, test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import nock from 'nock' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { executeApplicationCreate } from '../../src/commands/application-create.js' + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' +const REDIRECT_URI = 'https://example.com/callback' + +const BASE_OPTIONS = { + name: 'Test App', + key: API_KEY, + host: FA_HOST, +} + +function spaOptions(overrides = {}) { + return { ...BASE_OPTIONS, profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides } +} + +function webappOptions(overrides = {}) { + return { ...BASE_OPTIONS, profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides } +} + +// Minimal successful createApplication response +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + +// Minimal successful createApplication response with client secret (webapp) +const APP_RESPONSE_WITH_SECRET = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + clientSecret: 'super-secret', + }, + }, +} + +// Minimal successful system-configuration response (CORS already correct) +function systemConfigResponse(overrides = {}) { + return { + systemConfiguration: { + corsConfiguration: { + enabled: true, + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], + ...overrides, + }, + }, + } +} + +// Registers a GET /api/system-configuration mock reporting CORS as already +// compliant — used by every test where no CORS mutation should occur. +function mockCompliantSystemConfig() { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) +} + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +// --------------------------------------------------------------------------- +// Mode validation +// --------------------------------------------------------------------------- + +describe('mode validation', () => { + test('both --profile and --data provided returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...spaOptions(), + data: '{"name":"x"}', + }) + assert.equal(result.success, false) + assert.match(result.error, /mutually exclusive/) + }) + + test('neither --profile nor --data provided returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + }) + assert.equal(result.success, false) + assert.match(result.error, /required/) + }) + + test('--profile without --redirect-uri returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'spa', + }) + assert.equal(result.success, false) + assert.match(result.error, /--redirect-uri is required/) + }) + + test('--profile without --name returns error without making API calls', async () => { + const { name, ...optionsWithoutName } = spaOptions() + const result = await executeApplicationCreate(optionsWithoutName) + assert.equal(result.success, false) + assert.match(result.error, /--name is required/) + }) + + test('an invalid --profile value returns a clear error without making API calls', async () => { + // Direct library callers bypass Commander's .choices() validation, so + // executeApplicationCreate() must validate this itself rather than + // silently spreading `undefined` into an empty application object. + const result = await executeApplicationCreate(spaOptions({ profile: 'not-a-real-profile' })) + assert.equal(result.success, false) + assert.match(result.error, /--profile must be one of/) + assert.match(result.error, /spa/) + assert.match(result.error, /native/) + assert.match(result.error, /webapp/) + }) + + test('a --profile value that is an inherited Object property is rejected', async () => { + // `profile in profileDefaults` would incorrectly accept values like + // 'toString' or 'constructor', since `in` checks the prototype chain, + // not just own properties. profileDefaults['toString'] then resolves + // to the inherited Function, and {...profileDefaults['toString']} + // silently produces {} — reaching the exact "empty defaults, no + // security profile applied" bug this validation exists to prevent. + const result = await executeApplicationCreate(spaOptions({ profile: 'toString' })) + assert.equal(result.success, false) + assert.match(result.error, /--profile must be one of/) + }) +}) + +// --------------------------------------------------------------------------- +// --data parsing +// --------------------------------------------------------------------------- + +describe('--data parsing', () => { + test('inline JSON is parsed and sent', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ oauthConfiguration: { enabledGrants: ['authorization_code'] } }), + }) + assert.equal(result.success, true) + }) + + test('@file.json is read and parsed', async () => { + const tmp = path.join(os.tmpdir(), `test-app-${Date.now()}.json`) + fs.writeFileSync(tmp, JSON.stringify({ oauthConfiguration: {} })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + try { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: `@${tmp}`, + }) + assert.equal(result.success, true) + } finally { + fs.unlinkSync(tmp) + } + }) + + test('malformed inline JSON returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '{not valid json', + }) + assert.equal(result.success, false) + assert.match(result.error, /JSON/) + }) + + test('--data "null" returns a clear validation error without making API calls', async () => { + // JSON.parse('null') succeeds (it's valid JSON), so this isn't caught + // by the malformed-JSON case above. Without a shape check, this would + // otherwise surface as a confusing downstream TypeError instead. + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: 'null', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('--data as a JSON array returns a clear validation error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '[1,2,3]', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('--data as a JSON primitive returns a clear validation error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '42', + }) + assert.equal(result.success, false) + assert.match(result.error, /--data JSON must be a non-null, non-array object/) + }) + + test('missing @file returns error without making API calls', async () => { + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '@/does/not/exist.json', + }) + assert.equal(result.success, false) + assert.match(result.error, /Error reading --data file/) + }) + + test('--data mode preserves the JSON name when --name is omitted (full custom control)', async () => { + const { name, ...optionsWithoutName } = BASE_OPTIONS + + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.name, 'Name From JSON') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...optionsWithoutName, + data: JSON.stringify({ name: 'Name From JSON', oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) + + test('--data mode overrides the JSON name when --name is explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.name, 'Test App') // BASE_OPTIONS.name + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ name: 'Name From JSON', oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) + + test('--data mode preserves the JSON oauthConfiguration when the override flags are omitted', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.deepEqual(oauth.authorizedRedirectURLs, ['https://from-json.example.com/cb']) + assert.equal(oauth.logoutURL, 'https://from-json.example.com/logout') + assert.deepEqual(oauth.authorizedOriginURLs, ['https://from-json.example.com']) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ + oauthConfiguration: { + authorizedRedirectURLs: ['https://from-json.example.com/cb'], + logoutURL: 'https://from-json.example.com/logout', + authorizedOriginURLs: ['https://from-json.example.com'], + }, + }), + }) + assert.equal(result.success, true) + }) + + test('--redirect-uri overrides the JSON authorizedRedirectURLs when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.deepEqual(body.application.oauthConfiguration.authorizedRedirectURLs, [REDIRECT_URI]) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + redirectUri: [REDIRECT_URI], + data: JSON.stringify({ oauthConfiguration: { authorizedRedirectURLs: ['https://from-json.example.com/cb'] } }), + }) + assert.equal(result.success, true) + }) + + test('--logout-url overrides the JSON logoutURL when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.oauthConfiguration.logoutURL, 'https://override.example.com/logout') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + logoutUrl: 'https://override.example.com/logout', + data: JSON.stringify({ oauthConfiguration: { logoutURL: 'https://from-json.example.com/logout' } }), + }) + assert.equal(result.success, true) + }) + + test('--authorized-origin-url overrides the JSON authorizedOriginURLs when explicitly provided', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.deepEqual(body.application.oauthConfiguration.authorizedOriginURLs, ['https://override.example.com']) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + authorizedOriginUrl: ['https://override.example.com'], + data: JSON.stringify({ oauthConfiguration: { authorizedOriginURLs: ['https://from-json.example.com'] } }), + }) + assert.equal(result.success, true) + }) + + test('--data mode does not mutate system CORS configuration (unlike --profile spa)', async () => { + // Only register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + authorizedOriginUrl: ['https://override.example.com'], + data: JSON.stringify({ oauthConfiguration: {} }), + }) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Profile defaults — request body assertions +// --------------------------------------------------------------------------- + +describe('profile defaults', () => { + test('spa profile sends correct oauthConfiguration and jwtConfiguration', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + const jwt = body.application.jwtConfiguration + assert.deepEqual(oauth.enabledGrants, ['authorization_code', 'refresh_token']) + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + assert.equal(oauth.requireClientAuthentication, false) + assert.equal(oauth.generateRefreshTokens, true) + assert.equal(oauth.requireRegistration, true) + assert.deepEqual(oauth.authorizedRedirectURLs, [REDIRECT_URI]) + assert.equal(jwt.enabled, true) + assert.equal(jwt.timeToLiveInSeconds, 300) + assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(jwt.refreshTokenExpirationPolicy, 'SlidingWindow') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) + + test('native profile sends same oauth defaults as spa', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(oauth.clientAuthenticationPolicy, 'NotRequired') + assert.equal(oauth.requireRegistration, true) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'native', + redirectUri: ['myapp://callback'], + }) + assert.equal(result.success, true) + }) + + test('native profile does not call system-configuration', async () => { + // Native apps don't go through a browser's CORS enforcement, so + // --profile native should not touch CORS at all, unlike spa. Only + // register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + profile: 'native', + redirectUri: ['myapp://callback'], + }) + assert.equal(result.success, true) + }) + + test('webapp profile sends confidential client settings', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + const jwt = body.application.jwtConfiguration + assert.equal(oauth.proofKeyForCodeExchangePolicy, 'NotRequiredWhenUsingClientAuthentication') + assert.equal(oauth.clientAuthenticationPolicy, 'Required') + assert.equal(oauth.requireClientAuthentication, true) + assert.equal(oauth.requireRegistration, true) + assert.deepEqual(oauth.enabledGrants, ['authorization_code', 'refresh_token']) + assert.equal(jwt.timeToLiveInSeconds, 3600) + assert.equal(jwt.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(jwt.refreshTokenExpirationPolicy, 'SlidingWindow') + return true + }) + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('webapp profile does not call system-configuration', async () => { + // Only register /api/application — if system-configuration is called, + // nock will throw and the afterEach isDone() check will also fail. + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + }) + + test('optional profile options are included when provided', async () => { + // allowedOrigins already includes the origin below so this test can + // focus on the oauthConfiguration fields without also triggering the + // CORS-origin confirmation gate (covered separately). + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://example.com'] })) + + nock(FA_HOST) + .post('/api/application/', (body) => { + const oauth = body.application.oauthConfiguration + assert.deepEqual(oauth.authorizedOriginURLs, ['https://example.com']) + assert.equal(oauth.logoutURL, 'https://example.com/logout') + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://example.com'], + logoutUrl: 'https://example.com/logout', + })) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// ID overrides +// --------------------------------------------------------------------------- + +describe('ID overrides', () => { + test('--application-id is sent in the request URL and body', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post(`/api/application/${APP_ID}`, (body) => { + assert.equal(body.application.id, APP_ID) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ applicationId: APP_ID })) + assert.equal(result.success, true) + assert.equal(result.applicationId, APP_ID) + }) + + test('--application-id overrides id in --data', async () => { + const overrideId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + + nock(FA_HOST) + .post(`/api/application/${overrideId}`, (body) => { + assert.equal(body.application.id, overrideId) + return true + }) + .reply(200, { application: { id: overrideId, name: 'Test App', oauthConfiguration: { clientId: overrideId } } }) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ id: 'original-id', name: 'Test App' }), + applicationId: overrideId, + }) + assert.equal(result.success, true) + assert.equal(result.applicationId, overrideId) + }) + + test('--tenant-id overrides tenantId in --data', async () => { + nock(FA_HOST) + .post('/api/application/', (body) => { + assert.equal(body.application.tenantId, TENANT_ID) + return true + }) + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: JSON.stringify({ tenantId: 'original-tenant' }), + tenantId: TENANT_ID, + }) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Regression: tenant header scoping +// The X-FusionAuth-TenantId header must be absent on /api/system-configuration +// but present on /api/application when --tenant-id is supplied. +// --------------------------------------------------------------------------- + +describe('regression: tenant header scoping', () => { + test('X-FusionAuth-TenantId is absent on system-configuration call', async () => { + nock(FA_HOST, { + badheaders: ['X-FusionAuth-TenantId'], // fails if header IS present + }) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ tenantId: TENANT_ID })) + assert.equal(result.success, true) + }) + + test('X-FusionAuth-TenantId is present on createApplication call when --tenant-id supplied', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST, { + reqheaders: { 'x-fusionauth-tenantid': TENANT_ID }, + }) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ tenantId: TENANT_ID })) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Regression: error attribution +// A failure in ensureCorsHeaders must not be reported as "Error creating application". +// --------------------------------------------------------------------------- + +describe('regression: error attribution', () => { + test('system-configuration 401 returns error before createApplication is called', async () => { + // Register system-config to return 401 + nock(FA_HOST) + .get('/api/system-configuration') + .reply(401) + + // Do NOT register /api/application — if it were called, afterEach isDone() would pass + // incorrectly. We rely on the nock.pendingMocks() check being empty as the success signal, + // but more importantly we assert result.success is false here. + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, false) + // createApplication was never called, so the message must not be misattributed + // to it — it should describe the actual (CORS retrieval) failure. + assert.match(result.error, /Error retrieving system configuration/) + assert.doesNotMatch(result.error, /Error creating application/) + }) + + test('CORS patch failure returns error before createApplication is called', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) // missing headers → triggers patch + + nock(FA_HOST) + .patch('/api/system-configuration') + .reply(403) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, false) + assert.match(result.error, /Error updating CORS configuration/) + assert.doesNotMatch(result.error, /Error creating application/) + }) + + test('createApplication failure is correctly attributed to application creation', async () => { + // No CORS-related calls for webapp — createApplication is the only call made. + nock(FA_HOST) + .post('/api/application/') + .reply(500, {}) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + assert.match(result.error, /Error creating application/) + }) + + test('rawError preserves the original structured FusionAuth error rather than a generic wrapper', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(400, { fieldErrors: { name: [{ message: 'is required' }] } }) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + // rawError must be the original FusionAuth ClientResponse-shaped rejection + // (so errorAndExit/reportError can format fieldErrors/generalErrors), + // not the generic Error used for the human-readable `error` message. + assert.equal(result.rawError instanceof Error, false) + assert.equal(result.rawError.statusCode, 400) + assert.deepEqual(result.rawError.exception, { fieldErrors: { name: [{ message: 'is required' }] } }) + }) + + test('error message describes a duplicate-name failure instead of showing "[object Object]"', async () => { + // Real-world repro: creating an application with a name that already + // exists. FusionAuth's SDK rejects with a ClientResponse instance, + // which does NOT extend Error, so the old `e instanceof Error + // ? e.message : String(e)` always fell to String(e) — producing the + // literal, unhelpful "Error creating application: [object Object]". + nock(FA_HOST) + .post('/api/application/') + .reply(400, { generalErrors: [{ code: '[duplicate]', message: 'An Application with id or name [MyApp] already exists.' }] }) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, false) + assert.doesNotMatch(result.error, /\[object Object\]/) + // The detailed FusionAuth error lives in rawError (structured), not + // flattened into `error` — formatting that for display is a + // presentation-layer concern (see reportError()), not something baked + // into the result data itself. + assert.deepEqual(result.rawError.exception, { + generalErrors: [{ code: '[duplicate]', message: 'An Application with id or name [MyApp] already exists.' }], + }) + }) + + test('rawError is undefined for a direct, never-wrapped validation error', async () => { + // parseData() throws a plain Error directly (not via wrapError()), so + // it has no distinct .cause. Its message is already captured in + // `error` — if rawError also returned the same Error object, + // errorAndExit()/reportError() would print that message a second time. + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '{not valid json', + }) + assert.equal(result.success, false) + assert.equal(result.rawError, undefined) + }) +}) + +// --------------------------------------------------------------------------- +// CORS header management +// --------------------------------------------------------------------------- + +describe('CORS header management', () => { + test('no PATCH when all required headers already present (any casing)', async () => { + // Only GET is registered — a PATCH would cause nock to throw + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + allowedHeaders: ['DPoP', 'authorization', 'ACCEPT', 'Content-Type'], + })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) + + test('PATCH adds only missing headers, preserving existing ones', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: true, + allowedHeaders: ['Authorization', 'Content-Type'], // dpop and Accept missing + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const headers = body.systemConfiguration.corsConfiguration.allowedHeaders + assert(headers.includes('Authorization'), 'should preserve existing Authorization') + assert(headers.includes('Content-Type'), 'should preserve existing Content-Type') + assert(headers.some(h => h.toLowerCase() === 'dpop'), 'should add dpop') + assert(headers.some(h => h.toLowerCase() === 'accept'), 'should add Accept') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH sets enabled:true when CORS is disabled', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: false, + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + assert.equal(body.systemConfiguration.corsConfiguration.enabled, true) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH adds --authorized-origin-url to the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://existing.example.com'] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + assert(origins.includes('https://existing.example.com'), 'should preserve existing origin') + assert(origins.includes('https://myapp.example.com'), 'should add the new origin') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('duplicate --authorized-origin-url values are not duplicated in the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + const count = origins.filter(o => o === 'https://myapp.example.com').length + assert.equal(count, 1, `'https://myapp.example.com' should appear exactly once, got ${count}`) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + // Same origin supplied twice via --authorized-origin-url. + authorizedOriginUrl: ['https://myapp.example.com', 'https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH when --authorized-origin-url is already in the system CORS allowlist', async () => { + // Headers/enabled already compliant too — only origins differ from the + // baseline, so this also exercises the "would otherwise early-return" + // path now correctly checking origins as well. + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://myapp.example.com'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH for origins when allowedOrigins already contains "*"', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['*'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('--authorized-origin-url is not required — no origin changes attempted when omitted', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Confirmation gate (--yes) for CORS mutation +// Mutating system-wide CORS configuration must be gated behind +// confirmOrExit()/--yes. +// --------------------------------------------------------------------------- + +describe('confirmation gate for CORS mutation', () => { + test('non-interactive without --yes aborts before patching CORS or creating the application', async (t) => { + // process.exit is mocked so confirmOrExit() throws instead of actually + // exiting (see utils.ts docstring) — the throw is caught by + // executeApplicationCreate's try/catch and returned as a normal result. + // In production (unmocked), confirmOrExit() can still exit the process + // directly for a non-interactive caller without yes=true — see the + // documented exception to the "always returns a result" contract on + // executeApplicationCreate's JSDoc. It's only this test's mock that + // turns that exit into a returned result instead. + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) // missing headers → would trigger patch + + // Deliberately no PATCH or POST /api/application interceptors registered — + // if either were called, afterEach's nock.isDone() check would fail. + + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('--yes bypasses the confirmation prompt and proceeds with the CORS patch', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration') + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + + assert.equal(result.success, true) + }) + + test('a missing authorized origin alone (headers/enabled already compliant) still requires confirmation', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) // headers/enabled compliant; no allowedOrigins at all + + // Deliberately no PATCH or POST /api/application interceptors registered. + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + }) +}) + +// --------------------------------------------------------------------------- +// Output — clientSecret presence/absence +// --------------------------------------------------------------------------- + +describe('output', () => { + test('clientSecret is returned for webapp profile', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('spa profile result includes name/applicationId/clientId and omits clientSecret', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) // no clientSecret in response + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, undefined) + assert.equal(result.name, 'Test App') + assert.equal(result.applicationId, APP_ID) + assert.equal(result.clientId, APP_ID) + }) +}) diff --git a/__tests__/commands/application-get.test.js b/__tests__/commands/application-get.test.js new file mode 100644 index 0000000..93b28cb --- /dev/null +++ b/__tests__/commands/application-get.test.js @@ -0,0 +1,73 @@ +import { describe, test, beforeEach, afterEach, run } from 'node:test' +import assert, { throws } from 'node:assert/strict' +import nock from 'nock' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { executeGet } from '../../src/commands/application/get.js' +import { chdir, cwd } from 'node:process' + + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' + +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + + + +const BASE_OPTIONS = { + key: API_KEY, + host: FA_HOST, +} + + +describe('application:get options checks', () => { + + test('returns data when no output is specified', async () => { + nock(FA_HOST) + .get(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const result = await executeGet(APP_ID, BASE_OPTIONS) + assert.equal(result.success, true) + assert.deepEqual(result.data, APP_RESPONSE) + + }) + test('writes to specified file when provided', async () => { + nock(FA_HOST) + .get(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `/test-app-${Date.now()}`)) + try { + await executeGet(APP_ID, {...BASE_OPTIONS, output: tmpRoot + "/myFile.json"}) + const fileExists = fs.existsSync(tmpRoot + '/myFile.json') + assert.equal(fileExists, true, "Response file not created") + } catch(e) { + console.log(e) + } finally { + fs.rmSync(tmpRoot, {recursive: true}) + } + + }) + +}) \ No newline at end of file diff --git a/__tests__/commands/application-update.test.js b/__tests__/commands/application-update.test.js new file mode 100644 index 0000000..6a4d177 --- /dev/null +++ b/__tests__/commands/application-update.test.js @@ -0,0 +1,156 @@ +import { describe, test, beforeEach, afterEach, run } 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 { executeUpdateAction, setNestedProps, splitProp } from '../../src/commands/application/update.js' + + +beforeEach(() => { + process.env.NODE_ENV = 'test' + nock.cleanAll() +}) + +afterEach(() => { + // Fail if any registered nock interceptors were not consumed + assert(nock.isDone(), `Unused nock interceptors: ${JSON.stringify(nock.pendingMocks())}`) +}) + +const FA_HOST = 'http://localhost:9011' +const API_KEY = 'test-api-key' +const APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32' + +const APP_RESPONSE = { + application: { + id: APP_ID, + name: 'Test App', + oauthConfiguration: { + clientId: APP_ID, + }, + }, +} + + + +const BASE_OPTIONS = { + key: API_KEY, + host: FA_HOST, +} + +describe("test action function", () => { + + test("no data or props should error", async () => { + const result = await executeUpdateAction(APP_ID, {...BASE_OPTIONS}) + await assert.equal(result.success, false) + }) + + test("Prop option errors with improper syntax", async () => { + const result = await executeUpdateAction(APP_ID, {...BASE_OPTIONS, prop: ["something"]}) + await assert.equal(result.success, false) + // await assert.rejects(() => executeUpdateAction(APP_ID, {...BASE_OPTIONS, prop: ["something"]})) + }) + + test("errors when response isn't 200", async () => { + const tmp = path.join(os.tmpdir() + "test.json") + const testJSON = { + application: { + authenticationTokenConfiguration: { + enabled: false + }, + baseURL: "http://myurl.com3" + } + } + fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) + + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(400, APP_RESPONSE) + + const propResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}) + const dataResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, data: tmp}) + await assert.equal(propResult.success, false) + await assert.equal(dataResult.success, false) + + }) + test("success when response is 200 for prop", async () => { + const tmp = path.join(os.tmpdir() + "test.json") + const testJSON = { + application: { + authenticationTokenConfiguration: { + enabled: false + }, + baseURL: "http://myurl.com3" + } + } + fs.writeFileSync(tmp, JSON.stringify(testJSON, null, 2)) + + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const dataResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something2=somethingelse"]}) + await assert.equal(dataResult.success, true) + + }) + test("success when response is 200 for prop", async () => { + nock(FA_HOST) + .patch(`/api/application/${APP_ID}`) + .reply(200, APP_RESPONSE) + + const propResult = await executeUpdateAction((APP_ID), {...BASE_OPTIONS, prop: ["something=somethingelse"]}) + await assert.equal(propResult.success, true) + + }) + + +}) + +describe("test utiltiy functions for update", () => { + test("setNestedProps functions properly", () => { + let obj = {} + const propString = "prop.propString" + const propStringValue = "test" + const propBool = "prop.propBool" + const propBoolValue = false + const propObject = "prop.propObject" + const propObjValue = { name: "value" } + const propArray = "prop.propArray" + const propArrayValue = [1,2,3] + + const expectedObject = { + prop: { + propString: "test", + propBool: false, + propObject: { name: "value" }, + propArray: [1,2,3], + deep: { + deeper: { + deepest: "string" + } + } + } + } + + setNestedProps(obj, propString, propStringValue) + setNestedProps(obj, propBool, propBoolValue) + setNestedProps(obj, propObject, propObjValue) + setNestedProps(obj, propArray, propArrayValue) + setNestedProps(obj, "prop.deep.deeper.deepest", "string") + assert.deepEqual(obj, expectedObject) + }), + test("splitProp works", () => { + const simpleString = 'myVar=myValue' + const actualObj = splitProp(simpleString) + const expectedObj = { key: "myVar", value: "myValue"} + assert.deepEqual(actualObj, expectedObj) + const arrayString = 'myArray=["string", "string2", 1]' + const actualArrayObj = splitProp(arrayString) + const expectedArrayObj = { key: "myArray", value: ["string", "string2", 1]} + assert.deepEqual(actualArrayObj, expectedArrayObj) + const objString = 'myObj={"name": "hi"}' + const actualObjObj = splitProp(objString) + const expectedObjObj = { key: "myObj", value: {name: "hi"}} + assert.deepEqual(actualObjObj, expectedObjObj) + }) +}) diff --git a/__tests__/commands/import-generate.test.js b/__tests__/commands/import-generate.test.js new file mode 100644 index 0000000..c64fc1f --- /dev/null +++ b/__tests__/commands/import-generate.test.js @@ -0,0 +1,64 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { getDeprecatedFlagUsage, importGenerate } from "../../src/commands/import-generate.js" + +describe('getDeprecatedFlagUsage()', () => { + test('returns empty array when no deprecated flags are used', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--number-of-files', '5']) + assert.deepEqual(usage, []) + }) + + test('detects a bare deprecated flag (--flag value form)', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects a deprecated flag in --flag=value form', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles=5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects multiple deprecated flags used together', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5', '--groupId=abc']) + const oldFlags = usage.map(([old]) => old) + assert.ok(oldFlags.includes('--numberOfFiles')) + assert.ok(oldFlags.includes('--groupId')) + assert.equal(usage.length, 2) + }) + + test('does not flag the new kebab-case form as deprecated', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--group-id', 'abc']) + assert.deepEqual(usage, []) + }) +}) + +describe('import:generate option parsing', () => { + test('deprecated --numberOfFiles populates the same option as --number-of-files', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--numberOfFiles', '5'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '5') + }) + + test('--number-of-files populates the same numberOfFiles property', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--number-of-files', '7'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '7') + }) + + test('deprecated --groupId populates the same option as --group-id', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--groupId', 'abc-123'], { from: 'user' }) + + assert.equal(capturedOptions.groupId, 'abc-123') + }) +}) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js new file mode 100644 index 0000000..ea24a7f --- /dev/null +++ b/__tests__/commands/kickstart-install.test.js @@ -0,0 +1,452 @@ +import { describe, test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + validateEmail, + validatePassword, + resolveInstallAnswers, + resolveResourcesDir, +} from '../../src/commands/kickstart-install.js' + +// --------------------------------------------------------------------------- +// validateEmail +// --------------------------------------------------------------------------- + +describe('validateEmail()', () => { + test('accepts a standard email address', () => { + 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) + }) +}) + +// --------------------------------------------------------------------------- +// validatePassword +// --------------------------------------------------------------------------- + +describe('validatePassword()', () => { + test('accepts a password of exactly 8 characters', () => { + 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) + assert.match(result, /required/) + }) + + test('rejects a password one character short of the minimum', () => { + const result = validatePassword('1234567') + assert.notEqual(result, true) + assert.match(result, /8 characters/) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI options only (no prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — all options provided', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.TEST_ADMIN_PASS + process.env.TEST_ADMIN_PASS = 'supersecret' + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.TEST_ADMIN_PASS + } else { + process.env.TEST_ADMIN_PASS = savedEnv + } + }) + + test('returns answers from CLI options without calling promptFn', async () => { + const neverCallMe = () => { + throw new Error('promptFn should not have been called') + } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'TEST_ADMIN_PASS', + applicationName: 'My App', + }, + neverCallMe + ) + + assert.equal(answers.email, 'agent@example.com') + assert.equal(answers.password, 'supersecret') + assert.equal(answers.appName, 'My App') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — no CLI options (all prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — no options provided', () => { + test('calls promptFn with questions for all three fields', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers({}, mockPrompt) + + assert.equal(answers.email, 'prompted@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — partial CLI options +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — only adminEmail provided', () => { + test('does not include email in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers( + { adminEmail: 'cli@example.com' }, + mockPrompt + ) + + assert.equal(answers.email, 'cli@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('email'), 'should not ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +describe('resolveInstallAnswers() — only applicationName provided', () => { + test('does not include appName in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass' } + } + + const answers = await resolveInstallAnswers( + { applicationName: 'CLI App' }, + mockPrompt + ) + + assert.equal(answers.appName, 'CLI App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('appName'), 'should not ask for appName') + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — adminPasswordEnv resolution +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — admin-password-env', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.MY_ADMIN_PASS + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.MY_ADMIN_PASS + } else { + process.env.MY_ADMIN_PASS = savedEnv + } + }) + + test('reads the password from the named environment variable', async () => { + process.env.MY_ADMIN_PASS = 'envpassword' + + const neverCallMe = () => { throw new Error('promptFn should not have been called') } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'Test App', + }, + neverCallMe + ) + + assert.equal(answers.password, 'envpassword') + }) + + test('throws when the named environment variable is not set', async () => { + delete process.env.MY_ADMIN_PASS + + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'agent@example.com', adminPasswordEnv: 'MY_ADMIN_PASS', applicationName: 'App' }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /MY_ADMIN_PASS/) + assert.match(err.message, /not set/) + return true + } + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI validation errors +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — CLI validation errors', () => { + test('throws on invalid --admin-email', async () => { + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'not-an-email', adminPasswordEnv: undefined, applicationName: undefined }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-email/) + assert.match(err.message, /valid email/) + return true + } + ) + }) + + test('throws when env var password is too short', async () => { + process.env.MY_ADMIN_PASS = 'short' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /8 characters/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) + + test('throws when env var password is empty', async () => { + process.env.MY_ADMIN_PASS = '' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /required/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — inquirer validate functions are wired correctly +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — inquirer validate functions', () => { + test('email question uses validateEmail directly', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const emailQuestion = capturedQuestions.find((q) => q.name === 'email') + assert.ok(emailQuestion, 'email question should exist') + assert.equal(emailQuestion.validate, validateEmail) + }) + + test('password question uses validatePassword directly', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const passwordQuestion = capturedQuestions.find((q) => q.name === 'password') + assert.ok(passwordQuestion, 'password question should exist') + assert.equal(passwordQuestion.validate, validatePassword) + }) +}) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() +// --------------------------------------------------------------------------- +// +// These use synthetic temp directories (via the injectable baseDir param) +// rather than the real repo layout, so all three logic branches are +// deterministically exercised regardless of whether a build has run — +// including the dist-layout branch, which this test file could never reach +// otherwise, since it always imports src/commands/kickstart-install.js via +// tsx, fixing __dirname to .../src/commands for the whole test run. + +describe('resolveResourcesDir()', () => { + const createdDirs = [] + + function mkTempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of createdDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns baseDir/resources when it exists (dist layout)', () => { + const baseDir = mkTempDir('resolve-resources-dist-') + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('falls back to baseDir/../resources when baseDir/resources is missing (src layout)', () => { + const parent = mkTempDir('resolve-resources-src-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + const expected = path.join(parent, 'resources') + fs.mkdirSync(expected) + // Deliberately no baseDir/resources — only the parent-level fallback exists. + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('prefers the dist layout when both exist', () => { + const parent = mkTempDir('resolve-resources-both-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + fs.mkdirSync(path.join(parent, 'resources')) + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('throws a clear error when neither layout exists', () => { + const baseDir = mkTempDir('resolve-resources-none-') + + assert.throws( + () => resolveResourcesDir(baseDir), + /Could not locate kickstart resources directory/ + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() against the real built artifact (dist/) +// --------------------------------------------------------------------------- +// +// The tests above verify the function's logic in isolation; this verifies +// the actual distributable: that `npm run build`'s copy-files step really +// produces a dist/commands/resources directory the compiled command can +// find at its real __dirname, with the files kickstart:install needs. +// Skipped (not failed) when dist/ hasn't been built yet, so `test:unit` +// still works without requiring a build first — this is CI-meaningful +// since CI always runs `npm run build` before `npm test`. + +describe('resolveResourcesDir() against dist/ (built artifact)', () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const distModulePath = '../../dist/commands/kickstart-install.js' + const distModuleFile = path.join(__dirname, distModulePath) + + test('resolves dist/commands/resources from the compiled module', async (t) => { + if (!fs.existsSync(distModuleFile)) { + t.skip('dist/ has not been built — run `npm run build` first to exercise this test') + return + } + + const dist = await import(distModulePath) + const resourcesDir = dist.resolveResourcesDir() + + assert.equal(resourcesDir, path.join(path.dirname(distModuleFile), 'resources')) + assert.ok(fs.existsSync(resourcesDir), `${resourcesDir} should exist`) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'fusionauth')), + `${resourcesDir}/kickstart/fusionauth should exist` + ) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'kickstart.json')), + `${resourcesDir}/kickstart/kickstart.json should exist` + ) + }) +}) diff --git a/__tests__/commands/kickstart-kill.test.js b/__tests__/commands/kickstart-kill.test.js new file mode 100644 index 0000000..37bcf08 --- /dev/null +++ b/__tests__/commands/kickstart-kill.test.js @@ -0,0 +1,128 @@ +import { describe, test, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import { action } from "../../src/commands/kickstart-kill.js" + +/** + * Fake child-process-like object returned by mocked spawn — supports the + * minimal surface kickstart-kill's action() touches (.on, .stdout) without + * running any real process. + */ +function fakeChildProcess() { + return { + on: () => {}, + stdout: undefined, + } +} + +describe('kickstart:kill action()', () => { + // action() calls logEvent() internally (unmocked). Disable telemetry so it + // short-circuits before touching the real filesystem or network. + beforeEach(() => { + process.env.FUSIONAUTH_TELEMETRY = 'false' + }) + + afterEach(() => { + delete process.env.FUSIONAUTH_TELEMETRY + }) + + test('does not call confirmOrExit or spawn when Docker is not installed', async () => { + const confirmCalls = [] + const spawnCalls = [] + + await action( + { yes: false }, + { + isDockerInstalled: () => false, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('does not call confirmOrExit or spawn when CLI_DIR does not match cwd', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = '/not/the/current/directory' + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('yes=true calls confirmOrExit (which resolves immediately) then spawn', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: true }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 1, 'confirmOrExit should be called once') + assert.equal(confirmCalls[0][1], true, 'confirmOrExit should receive yes=true') + assert.equal(spawnCalls.length, 1, 'spawn should be called once') + assert.equal(spawnCalls[0][0], 'docker compose down -v') + }) + + test('when confirmOrExit rejects (declined/non-interactive), spawn is never called', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async () => { throw new Error('declined') }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) +}) diff --git a/__tests__/helpers/temp-dir.js b/__tests__/helpers/temp-dir.js new file mode 100644 index 0000000..6eb841c --- /dev/null +++ b/__tests__/helpers/temp-dir.js @@ -0,0 +1,26 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +/** + * Creates a real, unique temporary directory outside the repo (under the OS + * temp dir) for tests that need to exercise real filesystem behavior instead + * of mocking it. Prefer this over mock-fs, which has had shaky support for + * newer Node versions. + * + * @param prefix Prefix for the generated directory name. + * @returns The absolute path to the newly created temp directory. + */ +export function createTempDir(prefix = 'fa-cli-') { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) +} + +/** + * Recursively removes a temp directory created by createTempDir(). Safe to + * call even if the directory doesn't exist. + * + * @param dir The directory to remove. + */ +export function removeTempDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }) +} diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js new file mode 100644 index 0000000..8527a64 --- /dev/null +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -0,0 +1,263 @@ +import { describe, test, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + startFusionAuthContainer, + stopFusionAuthContainer, + getApplication, + deleteApplication, + captureSystemConfigurationBaseline, + resetSystemConfiguration, + makeApiRequest, +} from '../setup.js' +import { executeApplicationCreate } from '../../../src/commands/application-create.js' + +const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' +const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept', 'content-type'] +const REDIRECT_URI = 'https://example.com/callback' + +describe('application:create integration tests', () => { + let fusionAuthUrl + let apiKey + let systemConfigBaseline + const createdApplicationIds = [] + + before(async () => { + const container = await startFusionAuthContainer() + fusionAuthUrl = container.url + apiKey = container.apiKey + systemConfigBaseline = await captureSystemConfigurationBaseline(apiKey) + }) + + after(async () => { + await stopFusionAuthContainer() + }) + + afterEach(async () => { + // Delete any applications created during the test + for (const id of createdApplicationIds.splice(0)) { + try { await deleteApplication(id, apiKey) } catch (_) {} + } + // Restore CORS to the captured baseline + await resetSystemConfiguration(apiKey) + }) + + function baseOptions(overrides = {}) { + return { + name: `Integration Test App ${Date.now()}`, + key: apiKey, + host: fusionAuthUrl, + tenantId: TENANT_ID, + // Bypasses the CORS-change confirmation prompt (spa profile). + // The confirmation gate itself is covered by unit tests; these + // integration tests are focused on real API behavior. + yes: true, + ...overrides, + } + } + + function spaOptions(overrides = {}) { + return baseOptions({ profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides }) + } + + function webappOptions(overrides = {}) { + return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) + } + + // Verifies the required CORS headers (see REQUIRED_CORS_HEADERS) for the + // spa profile were added to system configuration, and that CORS is enabled. + async function assertCorsHeadersConfigured(apiKey) { + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = (sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? []) + .map(h => h.toLowerCase()) + for (const required of REQUIRED_CORS_HEADERS) { + assert(corsHeaders.includes(required), `CORS allowedHeaders should contain '${required}'`) + } + assert.equal(sysConfig.systemConfiguration.corsConfiguration?.enabled, true) + } + + // --------------------------------------------------------------------------- + // Happy paths — one per profile + // --------------------------------------------------------------------------- + + test('--profile spa creates application with correct settings and configures CORS', async () => { + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.applicationId, 'applicationId should be set') + assert.ok(result.clientId, 'clientId should be set') + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + // Verify application settings were persisted correctly + const app = await getApplication(result.applicationId, apiKey) + assert.ok(app, 'application should exist in FusionAuth') + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireClientAuthentication, false) + assert.equal(app.oauthConfiguration.generateRefreshTokens, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code', 'refresh_token']) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, [REDIRECT_URI]) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + + // Verify CORS headers were added to system configuration + await assertCorsHeadersConfigured(apiKey) + }) + + test('--authorized-origin-url is added to the system CORS allowlist, not just the application', async () => { + const origin = 'https://myapp.example.com' + const result = await executeApplicationCreate(spaOptions({ authorizedOriginUrl: [origin] })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + + // The application-level setting (iframe/X-Frame-Options allowlist for + // hosted pages) is a separate concern from the system CORS allowlist + // below, but --authorized-origin-url should still populate it as before. + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual(app.oauthConfiguration.authorizedOriginURLs, [origin]) + + // The system-wide CORS allowlist must also include it, or the browser + // will block the spa's actual cross-origin requests to the API despite + // CORS being "configured" (headers/enabled only, per the bug this + // guards against). + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const allowedOrigins = sysConfig.systemConfiguration.corsConfiguration?.allowedOrigins ?? [] + assert(allowedOrigins.includes(origin), `system CORS allowedOrigins should contain '${origin}'`) + }) + + test('--profile native creates application without touching system CORS configuration', async () => { + const result = await executeApplicationCreate(baseOptions({ + profile: 'native', + redirectUri: ['myapp://callback'], + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['myapp://callback']) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + + // Native apps don't go through a browser's CORS enforcement, so + // --profile native should leave system CORS configuration untouched, + // unlike spa. + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + assert.deepEqual(sysConfig.systemConfiguration.corsConfiguration, systemConfigBaseline.corsConfiguration) + }) + + test('--profile webapp creates confidential client and returns clientSecret', async () => { + const result = await executeApplicationCreate(webappOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.clientSecret, 'webapp should have a client secret') + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'NotRequiredWhenUsingClientAuthentication') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'Required') + assert.equal(app.oauthConfiguration.requireClientAuthentication, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 3600) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + }) + + test('--data custom mode creates application with provided configuration', async () => { + const customApp = { + oauthConfiguration: { + enabledGrants: ['authorization_code', 'refresh_token'], + authorizedRedirectURLs: ['https://custom.example.com/cb'], + generateRefreshTokens: true, + }, + } + + const result = await executeApplicationCreate(baseOptions({ + data: JSON.stringify(customApp), + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual( + app.oauthConfiguration.authorizedRedirectURLs, + ['https://custom.example.com/cb'] + ) + assert( + app.oauthConfiguration.enabledGrants.includes('authorization_code'), + 'should include authorization_code grant' + ) + }) + + // --------------------------------------------------------------------------- + // CORS idempotency + // --------------------------------------------------------------------------- + + test('running spa create twice does not duplicate CORS headers', async () => { + // First create + const result1 = await executeApplicationCreate(spaOptions()) + assert.equal(result1.success, true) + createdApplicationIds.push(result1.applicationId) + + // Second create without resetting CORS + const result2 = await executeApplicationCreate(spaOptions({ redirectUri: ['https://example.com/callback2'] })) + assert.equal(result2.success, true) + createdApplicationIds.push(result2.applicationId) + + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? [] + const corsHeadersLower = corsHeaders.map(h => h.toLowerCase()) + + // Each required header should appear exactly once + for (const required of REQUIRED_CORS_HEADERS) { + const count = corsHeadersLower.filter(h => h === required).length + assert.equal(count, 1, `'${required}' should appear exactly once in CORS allowedHeaders, got ${count}`) + } + }) + + // --------------------------------------------------------------------------- + // Regression: tenant header scoping (live server) + // Confirms /api/system-configuration actually accepts the request without + // the X-FusionAuth-TenantId header, which was the root cause of the 401. + // --------------------------------------------------------------------------- + + test('system-configuration is reachable without tenant header when --tenant-id is provided', async () => { + // If the tenant header were incorrectly sent to /api/system-configuration, + // this would fail with 401 — exactly the bug we fixed. + // (--tenant-id is already the default in baseOptions()/spaOptions().) + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + }) + + // --------------------------------------------------------------------------- + // --application-id override + // --------------------------------------------------------------------------- + + test('--application-id is respected and application is created with that ID', async () => { + const customId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const result = await executeApplicationCreate(webappOptions({ applicationId: customId })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.equal(result.applicationId, customId) + + createdApplicationIds.push(customId) + + const app = await getApplication(customId, apiKey) + assert.ok(app, 'application should exist with the specified ID') + assert.equal(app.id, customId) + }) +}) diff --git a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml index 6ac70fb..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 @@ -46,7 +44,7 @@ services: - search_net fusionauth: - image: fusionauth/fusionauth-app:latest + image: fusionauth/fusionauth-app:1.69.2 depends_on: db: condition: service_healthy @@ -68,7 +66,6 @@ services: networks: - db_net - search_net - restart: unless-stopped ports: - 9011:9011 volumes: diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index f27ae2e..7059e6b 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -2,6 +2,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' /** * Async version of exec used in place of execSync to avoid blocking the @@ -16,13 +17,138 @@ const execAsync = promisify(exec) * Handles docker compose lifecycle and FusionAuth readiness checks */ -const FUSIONAUTH_URL = 'http://localhost:9011' +const DEFAULT_FUSIONAUTH_URL = 'http://localhost:9011' const DEFAULT_API_KEY = '90dd6b25-d1ef-4175-9656-159dd994932e' -const HEALTH_CHECK_TIMEOUT = 120000 // 2 minutes +const HEALTH_CHECK_TIMEOUT = 240000 // 4 minutes const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const REQUEST_TIMEOUT = 10000 // 10 seconds +// fileURLToPath() (not .pathname) is required here: .pathname leaves +// characters like spaces percent-encoded (e.g. '%20'), which is not a +// valid path component on disk and would break both writeFileSync(envFile) +// and every docker compose invocation below for a checkout under a path +// containing a space. +const COMPOSE_DIR = fileURLToPath(new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url)) let isContainerRunning = false +let resolvedFusionAuthUrl = DEFAULT_FUSIONAUTH_URL + +/** + * Best-effort teardown used by the SIGINT/SIGTERM handlers below. Unlike + * stopFusionAuthContainer(), this does not check isContainerRunning — a + * termination signal can arrive before that flag is set (e.g. while still + * waiting on docker compose up -d or the health check loop), by which point + * containers may already exist and still need cleaning up. + * @param {string} reason - what triggered the teardown, for logging + */ +async function forceTeardown(reason) { + if (process.env.SKIP_TEARDOWN === 'true') { + console.log(`ℹ Skipping container teardown on ${reason} (SKIP_TEARDOWN=true)`) + return + } + try { + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + isContainerRunning = false + } catch (err) { + console.error(`Warning: Failed to stop container during ${reason} cleanup: ${err.message}`) + } +} + +let handlingTerminationSignal = false + +/** + * Ensures a Ctrl+C (or kill) during a test run doesn't leak the FusionAuth + * container — without this, after()/t.after() hooks never run on an + * interrupted process, leaving containers running (or stopped-but-not- + * removed, which can then collide with the next run's `docker compose up`). + * @param {string} signal + */ +async function handleTerminationSignal(signal) { + if (handlingTerminationSignal) { + // Second signal while teardown is still in flight (e.g. docker compose + // down -v hung) — the user wants out now. Exit immediately rather than + // silently no-op'ing: once a SIGINT/SIGTERM listener is registered, + // Node no longer applies its default "second Ctrl+C just kills the + // process" behavior on its own, so we have to implement that ourselves. + console.log(`\n⚠ Received ${signal} again — forcing immediate exit (teardown may be incomplete).`) + process.exit(signal === 'SIGINT' ? 130 : 143) + } + handlingTerminationSignal = true + console.log(`\n⚠ Received ${signal}, cleaning up FusionAuth container before exiting...`) + await forceTeardown(signal) + process.exit(signal === 'SIGINT' ? 130 : 143) +} + +process.on('SIGINT', () => { void handleTerminationSignal('SIGINT') }) +process.on('SIGTERM', () => { void handleTerminationSignal('SIGTERM') }) + +/** + * Resolves the FusionAuth service container's ID via Docker Compose, + * rather than assuming Compose's default generated container name + * ('{project}-{service}-{index}'). That default only holds when + * COMPOSE_PROJECT_NAME is unset; if it's set (e.g. by a contributor's + * shell profile or CI wrapper), the real container name differs and a + * hard-coded guess would silently fail to match anything. + * @returns {Promise} The container ID, or '' if it can't be found + * (e.g. the compose project doesn't exist yet) — callers should treat + * that the same as a failed `docker inspect` and fall back gracefully. + */ +async function resolveContainerId() { + try { + const { stdout } = await execAsync('docker compose --env-file .env.test ps -q fusionauth', { cwd: COMPOSE_DIR }) + return stdout.trim() + } catch (_) { + return '' + } +} + +/** + * Resolves the FusionAuth URL. On environments where localhost port-mapping + * behaves differently (e.g. macOS Docker Desktop), falls back to the + * container's direct bridge IP to ensure authenticated requests succeed. + * @returns {Promise} + */ +async function resolveFusionAuthUrl() { + // First try localhost — if an authenticated request succeeds, use it. + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`${DEFAULT_FUSIONAUTH_URL}/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) return DEFAULT_FUSIONAUTH_URL + } catch (_) {} + + // Fall back to the container's direct bridge IP (works on macOS Docker Desktop + // where localhost port-mapping doesn't forward API-key auth correctly). + try { + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ips = stdout.trim().split(/\s+/).filter(Boolean) + for (const ip of ips) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`http://${ip}:9011/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) { + console.log(`ℹ Using container IP ${ip}:9011 (localhost port-mapping not compatible)`) + return `http://${ip}:9011` + } + } catch (_) {} + } + } catch (_) {} + + // Return localhost as a last resort — health check will catch startup failures. + return DEFAULT_FUSIONAUTH_URL +} /** * Start FusionAuth via docker compose @@ -31,14 +157,14 @@ let isContainerRunning = false export async function startFusionAuthContainer() { if (isContainerRunning || process.env.REUSE_CONTAINER === 'true') { console.log('ℹ Using existing FusionAuth container') - return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + return { url: resolvedFusionAuthUrl, apiKey: DEFAULT_API_KEY } } console.log('↻ Starting FusionAuth container via docker compose...') - const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname - const envFile = path.join(composeDir, '.env.test') - const kickstartFilePath = path.join(composeDir, 'kickstart.json') + const envFile = path.join(COMPOSE_DIR, '.env.test') + const kickstartFilePath = path.join(COMPOSE_DIR, 'kickstart.json') // Create .env.test file with test configuration const envContent = ` @@ -57,28 +183,44 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m fs.writeFileSync(envFile, envContent) try { - // Check for and tear down any existing containers first + // Check for and tear down any existing containers first. Use -a/--all — + // without it, docker compose ps only lists running/restarting + // containers, so a stopped-but-not-removed container from a prior + // interrupted run would be invisible here, this cleanup would be + // skipped entirely, and the `up -d` below would fail with + // "Conflict: container name already in use". + let psOutput = '' try { - const { stdout: psOutput } = await execAsync(`cd ${composeDir} && docker compose ps -q`) - if (psOutput.trim()) { - console.log('⚠ Found existing FusionAuth containers, tearing them down...') - await execAsync(`cd ${composeDir} && docker compose down -v`) - console.log('✓ Existing containers removed') - } + const result = await execAsync('docker compose --env-file .env.test ps -aq', { cwd: COMPOSE_DIR }) + psOutput = result.stdout } catch (e) { - // Container may not exist, that's fine + // `docker compose ps` itself failing (e.g. project has never existed) + // is fine — there's nothing to tear down. + } + + if (psOutput.trim()) { + console.log('⚠ Found existing FusionAuth containers, tearing them down...') + // Unlike the ps check above, a failure here means stale containers + // genuinely remain. Let it propagate (via the outer catch) instead of + // silently continuing into `up -d`, which would just hit the same + // naming conflict with a far more confusing error message. + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + console.log('✓ Existing containers removed') } // Start containers - await execAsync(`cd ${composeDir} && docker compose --env-file .env.test up -d`) + await execAsync('docker compose --env-file .env.test up -d', { cwd: COMPOSE_DIR }) // Wait for FusionAuth to be healthy await waitForFusionAuthReady() + // Resolve the URL that actually works for authenticated requests + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + isContainerRunning = true console.log('✓ FusionAuth container started and ready') - return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + return { url: resolvedFusionAuthUrl, apiKey: DEFAULT_API_KEY } } catch (err) { throw new Error(`Failed to start FusionAuth container: ${err.message}`) } @@ -101,10 +243,8 @@ export async function stopFusionAuthContainer() { console.log('↻ Stopping FusionAuth container...') - const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname - try { - await execAsync(`cd ${composeDir} && docker compose down -v`) + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { @@ -124,36 +264,61 @@ async function waitForFusionAuthReady() { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - const response = await fetch(`${FUSIONAUTH_URL}/api/status`, { + const response = await fetch(`${DEFAULT_FUSIONAUTH_URL}/api/status`, { signal: controller.signal }) clearTimeout(timeoutId) if (response.ok) { - // Verify authenticated API requests work by fetching tenants, there was a problem with the status returning OK but the Key did not work + // Verify the kickstart has run and the container is fully initialized + // by checking authenticated API access. Tries localhost first, then + // falls back to the container's direct bridge IP. let authReady = false const authStartTime = Date.now() while (Date.now() - authStartTime < 30000) { // 30 second timeout for auth readiness + // Try localhost first, only falling back to the container's direct + // bridge IP (via docker inspect) if localhost doesn't respond ok. + // Mirrors resolveFusionAuthUrl()'s ordering — on Docker Desktop the + // bridge IP generally isn't routable from the host, so localhost + // must be attempted first rather than being unconditionally + // overridden. + const urlsToTry = [DEFAULT_FUSIONAUTH_URL] try { - const authController = new AbortController() - const authTimeoutId = setTimeout(() => authController.abort(), 5000) - - const tenantsResponse = await fetch(`${FUSIONAUTH_URL}/api/tenant`, { - method: 'GET', - headers: { Authorization: DEFAULT_API_KEY }, - signal: authController.signal - }) - clearTimeout(authTimeoutId) - - if (tenantsResponse.ok) { - authReady = true - break + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ip = stdout.trim().split(/\s+/).filter(Boolean)[0] + if (ip) urlsToTry.push(`http://${ip}:9011`) + } catch (_) {} + + for (const checkUrl of urlsToTry) { + try { + const authController = new AbortController() + const authTimeoutId = setTimeout(() => authController.abort(), 5000) + + const tenantsResponse = await fetch(`${checkUrl}/api/tenant`, { + method: 'GET', + headers: { Authorization: DEFAULT_API_KEY }, + signal: authController.signal + }) + clearTimeout(authTimeoutId) + + if (tenantsResponse.ok) { + authReady = true + break + } + } catch (err) { + // Not reachable via this URL yet, try the next one } - } catch (err) { - // Auth not ready yet, retry } - + + if (authReady) { + break + } + await sleep(HEALTH_CHECK_INTERVAL) } @@ -182,7 +347,7 @@ async function waitForFusionAuthReady() { * @returns {Promise} */ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT_API_KEY) { - const url = `${FUSIONAUTH_URL}${path}` + const url = `${resolvedFusionAuthUrl}${path}` const headers = { Authorization: apiKey, 'Content-Type': 'application/json' @@ -212,7 +377,15 @@ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT ) } - return await response.json() + // Some successful responses (e.g. DELETE /api/application) have an + // empty body — calling response.json() directly throws in that case + // ("Unexpected end of JSON input"), so read as text first and only + // parse when there's actually something to parse. + const responseText = await response.text() + if (!responseText) { + return null + } + return JSON.parse(responseText) } catch (err) { if (err.name === 'AbortError') { throw new Error(`API request timeout: ${method} ${path}`) @@ -269,6 +442,57 @@ export async function getMessageTemplateByName(name, apiKey = DEFAULT_API_KEY) { return templates.find(t => t.name === name) } +/** + * Get application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getApplication(applicationId, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', `/api/application/${applicationId}`, null, apiKey) + return data.application +} + +/** + * Delete application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function deleteApplication(applicationId, apiKey = DEFAULT_API_KEY) { + // First deactivate, then hard-delete + await makeApiRequest('DELETE', `/api/application/${applicationId}`, null, apiKey) + await makeApiRequest('DELETE', `/api/application/${applicationId}?hardDelete=true`, null, apiKey) +} + +let baselineSystemConfiguration = null + +/** + * Captures the current system configuration as the baseline to restore to + * after CORS-mutating tests. Must be called once before any test that + * modifies system configuration (e.g. application:create --profile spa). + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function captureSystemConfigurationBaseline(apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + baselineSystemConfiguration = data.systemConfiguration + return baselineSystemConfiguration +} + +/** + * Restores system configuration to the captured baseline. Uses PUT (full + * overwrite) rather than PATCH so the restore is exact, not merged. + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function resetSystemConfiguration(apiKey = DEFAULT_API_KEY) { + if (!baselineSystemConfiguration) { + throw new Error('captureSystemConfigurationBaseline() must be called before resetSystemConfiguration()') + } + await makeApiRequest('PUT', '/api/system-configuration', { systemConfiguration: baselineSystemConfiguration }, apiKey) +} + /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep diff --git a/__tests__/postInstall/postinstall.test.js b/__tests__/postInstall/postinstall.test.js index cfcd096..3315af0 100644 --- a/__tests__/postInstall/postinstall.test.js +++ b/__tests__/postInstall/postinstall.test.js @@ -1,96 +1,73 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" +import path from "node:path" import { createConfig } from '../../src/utils.js' +import { createTempDir, removeTempDir } from '../helpers/temp-dir.js' -import mock from 'mock-fs' -import fs, { readdirSync, readFileSync } from 'node:fs' +import fs from 'node:fs' describe('postInstall runs properly', () => { + let tempDir + let configDir + + beforeEach(() => { + tempDir = createTempDir() + configDir = path.join(tempDir, 'dist', '.fa') + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('No config creates dir', () => { - mock({ - 'dist': {}, - }) - try { - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') - } finally { - mock.restore() - } + const configFileExists = createConfig(configDir) + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') }) + test('No dist directory, still create the directory and file', () => { - mock({ - "./": {} - }) - try { - const configFileExists = createConfig('dist/.fa') - assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') - } finally { - mock.restore() - } + // tempDir exists but the nested dist/.fa path does not yet. + const configFileExists = createConfig(configDir) + assert.equal(configFileExists, true, 'Config not created at dist/.fa/config.json') }) + test('No config creates full config file with expected types', () => { - mock({ - 'dist': {}, - }) - try { - const configFileExists = createConfig('dist/.fa') - const configObject = JSON.parse(readFileSync('dist/.fa/config.json')) - assert(configObject.telemetry, true, 'Default telemetry not set to true') - assert(typeof configObject.id, 'string', "ID doesn't exist or isn't a string") - } finally { - mock.restore() - } + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'))) + assert(configObject.telemetry, true, 'Default telemetry not set to true') + assert(typeof configObject.id, 'string', "ID doesn't exist or isn't a string") }) test('Complete config returns false', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true}) - } - } - }) - try { - assert.equal(createConfig('dist/.fa'), false, 'Postinstall did not return false properly') - } finally { - mock.restore() - } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', telemetry: true }) + ) + + assert.equal(createConfig(configDir), false, 'Postinstall did not return false properly') }) + test('No ID in config, but telemetry false', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({telemetry: false}) - } - } - }) - try { - createConfig('dist/.fa') - const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) - assert.equal(typeof configObject.id, 'string', 'No ID after run') - assert.equal(configObject.telemetry, false, 'Telemetry got reset') - } finally { - mock.restore() - } - }) - test('No telemetry in config, but ID', () => { - mock({ - dist: { - '.fa': { - 'config.json': JSON.stringify({id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb'}) - } - } - }) - try { - createConfig('dist/.fa') - const configObject = JSON.parse(fs.readFileSync('dist/.fa/config.json')) - assert.equal(configObject.id, '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', 'ID got reset') - assert.equal(configObject.telemetry, true, 'Telemetry did not get set') - } finally { - mock.restore() - } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ telemetry: false })) + + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'))) + assert.equal(typeof configObject.id, 'string', 'No ID after run') + assert.equal(configObject.telemetry, false, 'Telemetry got reset') }) + test('No telemetry in config, but ID', () => { + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb' }) + ) + createConfig(configDir) + const configObject = JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'))) + assert.equal(configObject.id, '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', 'ID got reset') + assert.equal(configObject.telemetry, true, 'Telemetry did not get set') + }) }) diff --git a/__tests__/telemetry/index.js b/__tests__/telemetry/index.js deleted file mode 100644 index a56d1f2..0000000 --- a/__tests__/telemetry/index.js +++ /dev/null @@ -1,148 +0,0 @@ -import test, { describe, after, before, beforeEach, afterEach } from "node:test" -import assert from "node:assert" -import fs, { readFileSync } from "node:fs" -import mock from "mock-fs" -import { telemetryUpdate } from "../../dist/commands/telemetry/telemetry-utils.js" -import { telemetryDisable } from "../../dist/commands/telemetry/telemetry-disable.js" -import { telemetryEnable } from "../../dist/commands/telemetry/telemetry-enable.js" -import path from "node:path" -import { loadConfig, logEvent } from "../../dist/utils.js" -import nock from 'nock' - -export function telemetry() { - const mockedTrueConfig = { - id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - telemetry: true, - version: '1.0' - } - const mockedFalseConfig = { - id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', - telemetry: false, - version: '1.0' - } - describe('telemetry runs properly', () => { - test("Creates config if no config exists", (t) => { - before(() => { - mock({ - "dist": {} - }) - }) - - const updatedConfig = telemetryUpdate(true) - assert(fs.existsSync('dist/.fa/config.json'), "File wasn't created") - }) - test("Only changes telemetry value", () => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - }) - - const updatedConfig = telemetryUpdate(true) - assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) - }) - - test("Enable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - }) - test("Disable works", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - }) - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - }) - test("Disable full command runs properly", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - }) - - // TODO: Add quiet flag to remove outputs - telemetryDisable.parse() - const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) - assert.equal(actualConfig.telemetry, false) - }) - test("Enable full command runs properly", (t) => { - before(() => { - mock({ - "dist/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - }) - - // TODO: Add quiet flag to remove outputs - telemetryEnable.parse() - const actualConfig = JSON.parse(fs.readFileSync('dist/.fa/config.json').toString()) - assert.equal(actualConfig.telemetry, true) - }) - }) - describe('tests for logEvent', () => { - test("If FUSIONAUTH_TELEMETRY === false don't run", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = false - }) - after(() => { - delete process.env.FUSIONAUTH_TELEMETRY - }) - - const response = await logEvent('test event') - assert.equal(response, false, "logEvent still fired") - }) - - test("If FUSIONAUTH_TELEMETRY === true DO run", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = true - nock('https://us.i.posthog.com') - .post('/batch/') - .reply(200) - }) - after(() => { - nock.cleanAll(); - delete process.env.FUSIONAUTH_TELEMETRY - }) - - const response = await logEvent('test event') - assert.equal(response, true, "logEvent didn't fire") - }) - test("If no .env, event submits", async (t) => { - before(() => { - nock('https://us.i.posthog.com') - .post('/batch/') - .reply(200) - }) - after(() => { - nock.cleanAll(); - }) - - assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') - const response = await logEvent('test event') - assert.equal(response, true, "logEvent didn't fire") - }) - - test("Disables warning after first log", async (t) => { - before(() => { - process.env.FUSIONAUTH_TELEMETRY = true - mock({ - "dist/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - }) - after(() => { - delete process.env.FUSIONAUTH_TELEMETRY - mock.restore() - }) - await logEvent('cli test') - const newConfig = await loadConfig() - assert.equal(newConfig.globalConfig.telemetryNoWarn, true) - }) - - }) -} diff --git a/__tests__/telemetry/telemetry.test.js b/__tests__/telemetry/telemetry.test.js index d562f94..798c9b8 100644 --- a/__tests__/telemetry/telemetry.test.js +++ b/__tests__/telemetry/telemetry.test.js @@ -1,13 +1,13 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" -import fs, { readFileSync } from "node:fs" -import mock from "mock-fs" +import fs from "node:fs" +import path from "node:path" import { telemetryUpdate } from "../../src/commands/telemetry/telemetry-utils.js" import { telemetryDisable } from "../../src/commands/telemetry/telemetry-disable.js" import { telemetryEnable } from "../../src/commands/telemetry/telemetry-enable.js" -import path from "node:path" -import { logEvent } from "../../src/utils.js" +import { logEvent, loadConfig } from "../../src/utils.js" import nock from 'nock' +import { createTempDir, removeTempDir } from '../helpers/temp-dir.js' const mockedTrueConfig = { id: '8c0a77f2-27e4-4284-b5d3-5618ec2a56eb', @@ -20,97 +20,134 @@ const mockedFalseConfig = { version: '1.0' } +// All telemetry helpers read/write a global config file at +// `${FUSIONAUTH_CONFIG_DIR}/.fa/config.json`. Point that at a fresh real +// temp directory per test rather than mocking the filesystem, and rather +// than letting these tests write to the real repo's src/.fa/ directory. describe('telemetry runs properly', () => { + let tempDir + let configPath + + beforeEach(() => { + tempDir = createTempDir() + process.env.FUSIONAUTH_CONFIG_DIR = tempDir + configPath = path.join(tempDir, '.fa', 'config.json') + }) + + afterEach(() => { + delete process.env.FUSIONAUTH_CONFIG_DIR + removeTempDir(tempDir) + }) + + function writeConfig(config) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, JSON.stringify(config)) + } + test("Creates config if no config exists", () => { - mock({ - "src": {} - }) - try { - const updatedConfig = telemetryUpdate(true) - assert(fs.existsSync('src/.fa/config.json'), "File wasn't created") - } finally { - mock.restore() - } + const updatedConfig = telemetryUpdate(true) + assert(fs.existsSync(configPath), "File wasn't created") }) test("Only changes telemetry value", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - try { - const updatedConfig = telemetryUpdate(true) - assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) - } finally { - mock.restore() - } + writeConfig(mockedFalseConfig) + const updatedConfig = telemetryUpdate(true) + assert.deepEqual(updatedConfig.globalConfig, mockedTrueConfig) }) test("Enable works", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) - try { - const actualConfig = telemetryUpdate(true) - assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") - } finally { - mock.restore() - } + writeConfig(mockedFalseConfig) + const actualConfig = telemetryUpdate(true) + assert.equal(actualConfig.globalConfig.telemetry, true, "Telemetry not set to true") }) test("Disable works", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) - try { - const actualConfig = telemetryUpdate(false) - assert.equal(actualConfig.globalConfig.telemetry, false, "Telemetry not set to false") - } finally { - mock.restore() - } + writeConfig(mockedTrueConfig) + const actualConfig = telemetryUpdate(false) + assert.equal(actualConfig.globalConfig.telemetry, false, "Telemetry not set to false") }) test("Disable full command runs properly", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedTrueConfig) - }) + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) + writeConfig(mockedTrueConfig) try { telemetryDisable.parse() - const actualConfig = JSON.parse(fs.readFileSync('src/.fa/config.json').toString()) + const actualConfig = JSON.parse(fs.readFileSync(configPath).toString()) assert.equal(actualConfig.telemetry, false) } finally { - mock.restore() + nock.cleanAll() } }) test("Enable full command runs properly", () => { - mock({ - "src/.fa/config.json": JSON.stringify(mockedFalseConfig) - }) + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) + writeConfig(mockedFalseConfig) try { telemetryEnable.parse() - const actualConfig = JSON.parse(fs.readFileSync('src/.fa/config.json').toString()) + const actualConfig = JSON.parse(fs.readFileSync(configPath).toString()) assert.equal(actualConfig.telemetry, true) } finally { - mock.restore() + nock.cleanAll() } }) - }) - describe('tests for logEvent', () => { +}) + +describe('tests for logEvent', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + process.env.FUSIONAUTH_CONFIG_DIR = tempDir + // 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(() => { + delete process.env.FUSIONAUTH_CONFIG_DIR + delete process.env.FUSIONAUTH_TELEMETRY + removeTempDir(tempDir) + }) + test("If FUSIONAUTH_TELEMETRY === false don't run", async () => { process.env.FUSIONAUTH_TELEMETRY = 'false' + const response = await logEvent('test event') + assert.equal(response, false, "logEvent still fired") + }) + test("If no .env, event submits", async () => { + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) try { + assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') const response = await logEvent('test event') - assert.equal(response, false, "logEvent still fired") + assert.equal(response, true, "logEvent didn't fire") } finally { - delete process.env.FUSIONAUTH_TELEMETRY + nock.cleanAll() } }) - test("If no .env, event submits", async () => { + // Ported forward from the now-removed __tests__/telemetry/index.js — this + // is the one test there that covered behavior not exercised anywhere + // else: that the "we collect anonymous data" warning only prints once. + test("Disables warning after first log", async () => { nock('https://us.i.posthog.com') + .persist() .post('/batch/') .reply(200) try { - assert.equal(process.env.FUSIONAUTH_TELEMETRY, undefined, 'Env variable FUSIONAUTH_TELEMETRY is defined') - const response = await logEvent('test event') + const response = await logEvent('cli test') assert.equal(response, true, "logEvent didn't fire") + const newConfig = loadConfig() + assert.equal(newConfig.globalConfig.telemetryNoWarn, true) } finally { nock.cleanAll() } }) - - }) +}) diff --git a/__tests__/utilities/kickstart/validator.test.js b/__tests__/utilities/kickstart/validator.test.js index 2e9196a..b71b0cf 100644 --- a/__tests__/utilities/kickstart/validator.test.js +++ b/__tests__/utilities/kickstart/validator.test.js @@ -1,7 +1,9 @@ -import { describe, test } from "node:test" +import { describe, test, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" -import mock from "mock-fs" +import fs from "node:fs" +import path from "node:path" import { KickstartValidator } from "../../../src/utilities/kickstart/validator.js" +import { createTempDir, removeTempDir } from "../../helpers/temp-dir.js" describe('KickstartValidator', () => { @@ -304,6 +306,16 @@ describe('KickstartValidator', () => { }) describe('validateFileExists()', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('should report error for missing file', () => { const validator = new KickstartValidator() const result = validator.validateFileExists('/nonexistent/file.json') @@ -313,37 +325,36 @@ describe('KickstartValidator', () => { }) test('should accept existing file', () => { - mock({ - '/test/kickstart.json': '{"requests": []}' - }) - try { - const validator = new KickstartValidator() - const result = validator.validateFileExists('/test/kickstart.json') - - assert.equal(result.valid, true) - assert.equal(result.errors.length, 0) - } finally { - mock.restore() - } + const filePath = path.join(tempDir, 'kickstart.json') + fs.writeFileSync(filePath, '{"requests": []}') + + const validator = new KickstartValidator() + const result = validator.validateFileExists(filePath) + + assert.equal(result.valid, true) + assert.equal(result.errors.length, 0) }) test('should report error if path is directory', () => { - mock({ - '/test/': {} - }) - try { - const validator = new KickstartValidator() - const result = validator.validateFileExists('/test') - - assert.equal(result.valid, false) - assert(result.errors.some(e => e.message.includes('not a file'))) - } finally { - mock.restore() - } + const validator = new KickstartValidator() + const result = validator.validateFileExists(tempDir) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.message.includes('not a file'))) }) }) describe('loadAndValidateJSON()', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + removeTempDir(tempDir) + }) + test('should return error if file not found', () => { const validator = new KickstartValidator() const result = validator.loadAndValidateJSON('/nonexistent.json') @@ -353,18 +364,14 @@ describe('KickstartValidator', () => { }) test('should return error if JSON is invalid', () => { - mock({ - '/test/bad.json': '{ invalid json }' - }) - try { - const validator = new KickstartValidator() - const result = validator.loadAndValidateJSON('/test/bad.json') - - assert.equal(result.valid, false) - assert(result.errors.some(e => e.category === 'schema_invalid')) - } finally { - mock.restore() - } + const filePath = path.join(tempDir, 'bad.json') + fs.writeFileSync(filePath, '{ invalid json }') + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert.equal(result.valid, false) + assert(result.errors.some(e => e.category === 'schema_invalid')) }) test('should load and parse valid JSON', () => { @@ -373,19 +380,15 @@ describe('KickstartValidator', () => { { method: 'POST', url: '/api/app' } ] } - mock({ - '/test/valid.json': JSON.stringify(config) - }) - try { - const validator = new KickstartValidator() - const result = validator.loadAndValidateJSON('/test/valid.json') - - assert('config' in result) - assert.equal(result.config.requests.length, 1) - assert('lineNumbers' in result) - } finally { - mock.restore() - } + const filePath = path.join(tempDir, 'valid.json') + fs.writeFileSync(filePath, JSON.stringify(config)) + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert('config' in result) + assert.equal(result.config.requests.length, 1) + assert('lineNumbers' in result) }) test('should include line numbers in result', () => { @@ -395,17 +398,13 @@ describe('KickstartValidator', () => { { method: 'POST', url: '/api/app2' } ] } - mock({ - '/test/valid.json': JSON.stringify(config) - }) - try { - const validator = new KickstartValidator() - const result = validator.loadAndValidateJSON('/test/valid.json') - - assert('lineNumbers' in result) - } finally { - mock.restore() - } + const filePath = path.join(tempDir, 'valid.json') + fs.writeFileSync(filePath, JSON.stringify(config)) + + const validator = new KickstartValidator() + const result = validator.loadAndValidateJSON(filePath) + + assert('lineNumbers' in result) }) }) }) diff --git a/__tests__/utilities/kickstart/variable-substitution.test.js b/__tests__/utilities/kickstart/variable-substitution.test.js index 62826d3..c593664 100644 --- a/__tests__/utilities/kickstart/variable-substitution.test.js +++ b/__tests__/utilities/kickstart/variable-substitution.test.js @@ -1,7 +1,6 @@ import { describe, test, afterEach } from "node:test" import assert from "node:assert/strict" import nock from "nock" -import mock from 'mock-fs' import { VariableSubstitutor } from "../../../src/utilities/kickstart/variable-substitution.js" describe('VariableSubstitutor', () => { diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js new file mode 100644 index 0000000..ecb754c --- /dev/null +++ b/__tests__/utils.test.js @@ -0,0 +1,158 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { isConfirmationAccepted, handleConfirmationAnswer, confirmOrExit, reportError } from "../src/utils.js" + +describe('isConfirmationAccepted()', () => { + test('accepts "y"', () => { + assert.equal(isConfirmationAccepted('y'), true) + }) + + test('accepts "yes"', () => { + assert.equal(isConfirmationAccepted('yes'), true) + }) + + test('accepts case-insensitive variants', () => { + assert.equal(isConfirmationAccepted('Y'), true) + assert.equal(isConfirmationAccepted('YES'), true) + assert.equal(isConfirmationAccepted('Yes'), true) + }) + + test('accepts whitespace-padded variants', () => { + assert.equal(isConfirmationAccepted(' y '), true) + assert.equal(isConfirmationAccepted(' yes '), true) + }) + + test('rejects "n"', () => { + assert.equal(isConfirmationAccepted('n'), false) + }) + + test('rejects empty string', () => { + assert.equal(isConfirmationAccepted(''), false) + }) + + test('rejects unrelated text', () => { + assert.equal(isConfirmationAccepted('nope'), false) + assert.equal(isConfirmationAccepted('ye'), false) + assert.equal(isConfirmationAccepted('sure'), false) + }) +}) + +describe('handleConfirmationAnswer()', () => { + test('accepted answer calls resolve, not reject or process.exit', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejected = false + + handleConfirmationAnswer('y', () => { resolved = true }, () => { rejected = true }) + + assert.equal(resolved, true) + assert.equal(rejected, false) + assert.equal(exitMock.mock.calls.length, 0) + }) + + test('declined answer calls process.exit(0)', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + handleConfirmationAnswer('n', () => {}, () => {}) + + assert.equal(exitMock.mock.calls.length, 1) + assert.equal(exitMock.mock.calls[0].arguments[0], 0) + }) + + test('declined answer rejects rather than resolving when process.exit is mocked (does not actually exit)', (t) => { + t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejectedWith + + handleConfirmationAnswer('n', () => { resolved = true }, (err) => { rejectedWith = err }) + + assert.equal(resolved, false, 'resolve should never be called on decline') + assert.ok(rejectedWith instanceof Error, 'reject should be called with an Error') + }) +}) + +describe('confirmOrExit()', () => { + test('yes=true resolves immediately without touching stdin/stdout', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + await confirmOrExit('This is risky', true) + + assert.equal(exitMock.mock.calls.length, 0, 'process.exit should not be called') + }) + + test('non-interactive (stdin not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = true + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (stdout not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = true + process.stdout.isTTY = false + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (both not TTYs) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = false + + try { + await assert.rejects(() => confirmOrExit('This is risky', false)) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) +}) + +// --------------------------------------------------------------------------- +// reportError() — unknown-shape fallback +// --------------------------------------------------------------------------- + +describe('reportError() fallback for unknown error shapes', () => { + test('does not throw and still prints something useful for a circular-reference object', (t) => { + // JSON.stringify (the old implementation, toJson) throws on circular + // references. util.inspect handles this gracefully instead. + const errorMock = t.mock.method(console, 'error', () => {}) + const circular = { statusCode: 500 } + circular.self = circular + + assert.doesNotThrow(() => reportError('Something went wrong', circular)) + + const printedLines = errorMock.mock.calls.map((call) => call.arguments[0]) + assert.ok(printedLines.some((line) => line.includes('statusCode')), 'should print the object contents') + }) +}) diff --git a/package-lock.json b/package-lock.json index 252d350..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": { @@ -30,6 +30,7 @@ "queue": "7.0.0", "remove-undefined-objects": "9.0.0", "uuid": "14.0.1", + "validator": "^13.15.35", "yocto-spinner": "^1.2.1" }, "bin": { @@ -41,10 +42,9 @@ "@types/figlet": "1.7.0", "@types/fs-extra": "11.0.4", "@types/html-to-text": "9.0.4", - "@types/mock-fs": "^4.13.4", "@types/node": "26.1.1", + "@types/validator": "^13.15.10", "husky": "^9.1.7", - "mock-fs": "^5.5.0", "nock": "^14.0.16", "tsx": "^4.23.0", "type-fest": "5.8.0", @@ -1309,16 +1309,6 @@ "@types/node": "*" } }, - "node_modules/@types/mock-fs": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", - "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1329,6 +1319,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2758,16 +2755,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mock-fs": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", - "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/mute-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", @@ -3224,6 +3211,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index bd605d5..4b3dbad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "description": "FusionAuth CLI", "main": "dist/index.js", "engines": { @@ -16,7 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "npm run build && node dist/index.js", - "test": "node --import tsx --test", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test --test-concurrency=1 '__tests__/integration/**/*.test.js'", + "test:unit": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test __tests__/utils.test.js '__tests__/postInstall/*.test.js' '__tests__/telemetry/*.test.js' '__tests__/utilities/**/*.test.js' '__tests__/commands/*.test.js'", "prepare": "husky" }, "keywords": [ @@ -56,6 +58,7 @@ "queue": "7.0.0", "remove-undefined-objects": "9.0.0", "uuid": "14.0.1", + "validator": "^13.15.35", "yocto-spinner": "^1.2.1" }, "devDependencies": { @@ -64,10 +67,9 @@ "@types/figlet": "1.7.0", "@types/fs-extra": "11.0.4", "@types/html-to-text": "9.0.4", - "@types/mock-fs": "^4.13.4", "@types/node": "26.1.1", + "@types/validator": "^13.15.10", "husky": "^9.1.7", - "mock-fs": "^5.5.0", "nock": "^14.0.16", "tsx": "^4.23.0", "type-fest": "5.8.0", diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts new file mode 100644 index 0000000..d5afbd4 --- /dev/null +++ b/src/commands/application-create.ts @@ -0,0 +1,502 @@ +import * as fs from 'node:fs'; +import {Command, Option} from '@commander-js/extra-typings'; +import boxen from 'boxen'; +import { + Application, + ClientAuthenticationPolicy, + CORSConfiguration, + FusionAuthClient, + GrantType, + ProofKeyForCodeExchangePolicy, + RefreshTokenExpirationPolicy, + RefreshTokenUsagePolicy, +} from '@fusionauth/typescript-client'; +import chalk from 'chalk'; +import {confirmOrExit, logEvent} from '../utils.js'; +import {apiKeyOption, hostOption} from '../options.js'; +import * as utils from '../utils.js'; + +type Profile = 'spa' | 'native' | 'webapp'; + +export interface ApplicationCreateOptions { + name?: string; + profile?: string; + redirectUri?: string[]; + logoutUrl?: string; + authorizedOriginUrl?: string[]; + applicationId?: string; + tenantId?: string; + data?: string; + yes?: boolean; + key: string; + host: string; +} + +export interface ApplicationCreateResult { + success: boolean; + error?: string; + rawError?: unknown; + applicationId?: string; + clientId?: string; + clientSecret?: string; + name?: string; +} + +// Shared refresh token policy (usage + expiration) across all profiles. A +// sliding window of one-time-use refresh tokens is the recommended default +// for spa/native/webapp. This does not include timeToLiveInSeconds — that's +// the access token (JWT) lifetime, set separately per profile below in +// jwtConfiguration, not part of the refresh token policy itself. +const defaultRefreshTokenPolicy = { + refreshTokenUsagePolicy: RefreshTokenUsagePolicy.OneTimeUse, + refreshTokenExpirationPolicy: RefreshTokenExpirationPolicy.SlidingWindow, +}; + +// requireRegistration: true means a user must have a registration for this +// application before they can complete the authorization_code/implicit grant. +// registrationConfiguration.enabled is intentionally left false (self-service +// registration is off), so registrations must be created out-of-band — e.g. +// via the Registration API — before a user can log in. See the "Create users" +// Next Steps link printed after a successful create. +function buildPublicClientDefaults(): Application { + return { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.Required, + clientAuthenticationPolicy: ClientAuthenticationPolicy.NotRequired, + requireClientAuthentication: false, + requireRegistration: true, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 300, + ...defaultRefreshTokenPolicy, + }, + }; +} + +const profileDefaults: Record = { + spa: buildPublicClientDefaults(), + native: buildPublicClientDefaults(), + webapp: { + oauthConfiguration: { + enabledGrants: [GrantType.authorization_code, GrantType.refresh_token], + generateRefreshTokens: true, + proofKeyForCodeExchangePolicy: ProofKeyForCodeExchangePolicy.NotRequiredWhenUsingClientAuthentication, + clientAuthenticationPolicy: ClientAuthenticationPolicy.Required, + requireClientAuthentication: true, + // See comment on buildPublicClientDefaults() above re: requireRegistration. + requireRegistration: true, + }, + jwtConfiguration: { + enabled: true, + timeToLiveInSeconds: 3600, + ...defaultRefreshTokenPolicy, + }, + }, +}; + +// Headers a spa app needs the browser to allow through CORS: 'dpop' and +// 'Authorization' for DPoP-bound bearer tokens, and 'Accept'/'Content-Type' +// because JSON request/response bodies are not CORS-safelisted by default +// (unlike e.g. application/x-www-form-urlencoded) — without 'Content-Type' +// here, a SPA sending `Content-Type: application/json` would still fail +// preflight even after this command reports CORS as configured. +const REQUIRED_CORS_HEADERS = ['dpop', 'Authorization', 'Accept', 'Content-Type']; + +/** + * Wraps an unknown error with additional context while preserving the + * original value (e.g. a FusionAuth `ClientResponse` rejection, which + * carries structured `fieldErrors`/`generalErrors`) as `.cause`, so callers + * further up the stack can still access it for rich reporting instead of + * only the flattened message string. + */ +function wrapError(message: string, cause: unknown): Error { + const error = new Error(message); + (error as Error & {cause?: unknown}).cause = cause; + return error; +} + +/** + * Unwraps an error produced by wrapError() back to its original cause, for + * use as ApplicationCreateResult.rawError — which exists specifically to + * carry structured detail (e.g. FusionAuth's fieldErrors/generalErrors) + * beyond the plain message already captured in ApplicationCreateResult.error. + * + * Returns undefined for a direct, never-wrapped Error (e.g. parseData()'s + * validation errors) — its message is already the `error` string, so + * returning the same Error object again as rawError would make + * errorAndExit()/reportError() print that message a second time. Only a + * genuinely-wrapped error's distinct .cause, or a rejection that was never + * an Error at all (e.g. a raw ClientResponse-shaped object thrown without + * wrapError()), is preserved — both can carry detail worth reporting. + */ +function unwrapError(e: unknown): unknown { + if (e instanceof Error) { + return 'cause' in e && e.cause !== undefined ? e.cause : undefined; + } + return e; +} + +/** + * Ensures that the required CORS headers (see REQUIRED_CORS_HEADERS) — and, + * when authorizedOrigins is non-empty, those origins — are present in the + * FusionAuth system configuration. Also enables CORS if it is currently + * disabled. Should be called for the spa profile before creating the + * application. + * + * Enabling CORS and allowing the right headers is not sufficient on its + * own: FusionAuth's CORS allowlist (corsConfiguration.allowedOrigins) is a + * separate, independent setting, and browsers will still block cross-origin + * requests from the spa app's own origin unless it's present there (or + * allowedOrigins is "*"). --authorized-origin-url is the only source of + * that origin available to this command, so it's reused here in addition + * to populating application.oauthConfiguration.authorizedOriginURLs. + * + * CORS only applies to browser-based requests, so this is only relevant + * for the spa profile — native apps don't go through a browser's CORS + * enforcement at all, so this should not be called for native. If this + * call fails the entire command is aborted — no application will be + * created. + * + * This mutates system-wide configuration, so it is gated behind + * confirmOrExit()/--yes and only prompts when a change is actually needed. + * + * Note: /api/system-configuration does not accept a tenant ID. The tenant + * header is cleared for these calls and restored afterward. + */ +async function ensureCorsHeaders(client: FusionAuthClient, yes: boolean, authorizedOrigins: string[]): Promise { + const originalTenantId = client.tenantId ?? null; + client.setTenantId(null); + + try { + let retrieveResponse; + try { + retrieveResponse = await client.retrieveSystemConfiguration(); + } catch (e: unknown) { + throw wrapError('Error retrieving system configuration', e); + } + + const systemConfig = retrieveResponse.response.systemConfiguration!; + const cors: CORSConfiguration = systemConfig.corsConfiguration ?? {}; + const existing: string[] = cors.allowedHeaders ?? []; + const existingLower = existing.map((h) => h.toLowerCase()); + + const missing = REQUIRED_CORS_HEADERS.filter( + (h) => !existingLower.includes(h.toLowerCase()) + ); + + // Origins are case-sensitive, unlike header names, and "*" already + // permits every origin — nothing to add in that case. Dedupe the + // supplied origins first — authorizedOrigins is only ever compared + // against the pre-existing allowlist below, so a duplicate within + // authorizedOrigins itself (e.g. --authorized-origin-url passed the + // same URL twice) would otherwise pass that filter twice and write + // a duplicate entry into the system-wide CORS configuration. + const existingOrigins: string[] = cors.allowedOrigins ?? []; + const dedupedAuthorizedOrigins = [...new Set(authorizedOrigins)]; + const missingOrigins = existingOrigins.includes('*') + ? [] + : dedupedAuthorizedOrigins.filter((o) => !existingOrigins.includes(o)); + + const needsEnable = cors.enabled !== true; + + if (missing.length === 0 && missingOrigins.length === 0 && !needsEnable) { + return; + } + + const changes = [ + ...(needsEnable ? ['enable CORS'] : []), + ...(missing.length > 0 ? [`add CORS header(s): ${missing.join(', ')}`] : []), + ...(missingOrigins.length > 0 ? [`add CORS allowed origin(s): ${missingOrigins.join(', ')}`] : []), + ].join(' and '); + + await confirmOrExit( + `This will modify your FusionAuth system configuration to ${changes}. ` + + 'This may allow cross-domain requests that were previously blocked.', + yes + ); + + try { + await client.patchSystemConfiguration({ + systemConfiguration: { + corsConfiguration: { + ...cors, + enabled: true, + allowedHeaders: [...existing, ...missing], + ...(missingOrigins.length > 0 + ? {allowedOrigins: [...existingOrigins, ...missingOrigins]} + : {}), + }, + }, + }); + + if (missing.length > 0) { + console.log(` CORS headers added: ${missing.join(', ')}`); + } + if (missingOrigins.length > 0) { + console.log(` CORS allowed origins added: ${missingOrigins.join(', ')}`); + } + } catch (e: unknown) { + throw wrapError('Error updating CORS configuration', e); + } + } finally { + client.setTenantId(originalTenantId); + } +} + +/** + * Parses the --data value. If it begins with '@', reads the referenced file. + * Otherwise parses the value as inline JSON. + * Throws an Error on parse, file-read, or shape failure (caught by + * executeApplicationCreate). "Shape failure" means the parsed JSON is valid + * but isn't a non-null, non-array object — e.g. `--data 'null'` or + * `--data '[1,2,3]'` would otherwise be cast to Application unchecked, + * surfacing as a confusing downstream TypeError (null) or a nonsensical + * API payload (array/primitive) instead of a clear validation error here. + */ +function parseData(data: string): Application { + let json: string; + if (data.startsWith('@')) { + const filePath = data.slice(1); + try { + json = fs.readFileSync(filePath, 'utf-8'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error reading --data file "${filePath}": ${message}`); + } + } else { + json = data; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error parsing --data JSON: ${message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `--data JSON must be a non-null, non-array object, got ${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.` + ); + } + return parsed as Application; +} + +/** + * Core logic for application:create. Returns a result object on every + * validation/API failure it detects itself, rather than calling + * process.exit() directly — this is what allows tests to import and invoke + * it, and non-CLI callers to handle failures programmatically. + * + * One exception: for the spa profile, this calls ensureCorsHeaders(), + * which calls confirmOrExit() to gate a system-wide CORS mutation per this + * project's Risky Operations convention (see kickstart-kill.ts for the + * same pattern elsewhere). confirmOrExit() does call process.exit() for a + * non-interactive caller without yes=true, or an interactive caller who + * declines — so a direct (non-CLI) caller in that situation will still see + * the process terminate rather than a returned result. Pass yes: true to + * avoid this when calling programmatically in a non-interactive context. + */ +export async function executeApplicationCreate(options: ApplicationCreateOptions): Promise { + const { + name, + profile, + redirectUri, + logoutUrl, + authorizedOriginUrl, + applicationId, + tenantId, + data, + yes, + key: apiKey, + host, + } = options; + + try { + await logEvent('cli command application:create'); + + // --- Mode validation --- + if (profile && data) { + return { success: false, error: '--profile and --data are mutually exclusive. Provide one or the other.' }; + } + if (!profile && !data) { + return { success: false, error: 'Either --profile or --data is required.' }; + } + + let application: Application; + + if (profile) { + // --- Profile mode --- + if (redirectUri === undefined || redirectUri.length === 0) { + return { success: false, error: '--redirect-uri is required when using --profile.' }; + } + if (!name) { + return { success: false, error: '--name is required when using --profile.' }; + } + if (!Object.keys(profileDefaults).includes(profile)) { + return { success: false, error: `--profile must be one of: ${Object.keys(profileDefaults).join(', ')}.` }; + } + + const defaults = profileDefaults[profile as Profile]; + application = {...defaults}; + application.name = name; + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedRedirectURLs: redirectUri, + ...(logoutUrl ? {logoutURL: logoutUrl} : {}), + ...(authorizedOriginUrl && authorizedOriginUrl.length > 0 + ? {authorizedOriginURLs: authorizedOriginUrl} + : {}), + }; + } else { + // --- Custom mode --- + // --data provides "full custom control": the JSON is the source of + // truth, and --name/--redirect-uri/--logout-url/--authorized-origin-url + // are all optional overrides that only take effect if explicitly + // passed, leaving the JSON's own values untouched otherwise. This + // mirrors the --application-id/--tenant-id override pattern below, + // which applies unconditionally in both modes. + application = parseData(data!); + if (name) { + application.name = name; + } + if (redirectUri && redirectUri.length > 0) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedRedirectURLs: redirectUri, + }; + } + if (logoutUrl) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + logoutURL: logoutUrl, + }; + } + if (authorizedOriginUrl && authorizedOriginUrl.length > 0) { + application.oauthConfiguration = { + ...application.oauthConfiguration, + authorizedOriginURLs: authorizedOriginUrl, + }; + } + // Note: unlike --profile mode, this does not call ensureCorsHeaders() + // — --data mode never mutates system-wide CORS configuration, since + // "full custom control" means the caller owns their own + // infrastructure config, not just the application body. + } + + // --- ID overrides (applied last in both modes) --- + if (applicationId) { + application.id = applicationId; + } + if (tenantId) { + application.tenantId = tenantId; + } + + const fusionAuthClient = new FusionAuthClient(apiKey, host, tenantId); + + // For the spa profile, enforce DPoP-required CORS headers (and + // allowed origins, when provided) first. If this fails (or the user + // declines the confirmation prompt), the command aborts — + // createApplication is never called. Native apps don't go through + // a browser's CORS enforcement, so this is intentionally skipped + // for --profile native. + if (profile === 'spa') { + await ensureCorsHeaders(fusionAuthClient, yes ?? false, authorizedOriginUrl ?? []); + } + + let clientResponse; + try { + clientResponse = await fusionAuthClient.createApplication( + application.id ?? '', + {application} + ); + } catch (e: unknown) { + throw wrapError('Error creating application', e); + } + + const created = clientResponse.response.application!; + // clientId intentionally mirrors applicationId here: FusionAuth does not + // allow oauthConfiguration.clientId to be set via the API (it's only + // ever returned, never accepted as input), so for applications created + // by this command the two values are always identical. + const clientId = created.oauthConfiguration?.clientId ?? created.id ?? ''; + const clientSecret = created.oauthConfiguration?.clientSecret; + + return { + success: true, + applicationId: created.id, + clientId, + clientSecret, + name: created.name, + }; + + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { success: false, error: message, rawError: unwrapError(e) }; + } +} + +/** + * CLI action wrapper — calls executeApplicationCreate and handles output/exit. + */ +const action = async function (options: ApplicationCreateOptions) { + utils.betaWarning(); + + const result = await executeApplicationCreate(options); + + if (!result.success) { + utils.errorAndExit(result.error ?? 'Error creating application.', result.rawError); + return; + } + + console.log(); + console.log(chalk.green('Application created.')); + console.log(` Name: ${result.name}`); + console.log(` Application ID / client_id: ${result.clientId}`); + if (result.clientSecret) { + console.log(` Client Secret: ${result.clientSecret}`); + } + + console.log(); + console.log(boxen( + [ + `Customize FusionAuth with a ${chalk.cyan('simple theme')}:`, + ' https://fusionauth.io/docs/customize/look-and-feel/simple-theme-editor', + '', + `Create ${chalk.cyan('users')}:`, + ' https://fusionauth.io/docs/lifecycle/register-users/', + '', + `Configure an ${chalk.cyan('SMTP server')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/configure-email', + '', + `Set up ${chalk.cyan('email templates')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/email-templates', + '', + `${chalk.cyan('Add login')} to your application:`, + ' https://fusionauth.io/docs/get-started/start-here/step-1', + '', + ].join('\n'), + {padding: 1, title: 'Next Steps', borderColor: 'green', borderStyle: 'bold'} + )); +}; + +// noinspection JSUnusedGlobalSymbols +export const applicationCreate = new Command('application:create') + .description('Create an application in FusionAuth') + .option('--name ', 'The name of the application (required with --profile; overrides the name in --data if provided)') + .addOption( + new Option('--profile ', 'Security profile to apply (mutually exclusive with --data)') + .choices(['spa', 'native', 'webapp'] as const) + ) + .option('--redirect-uri ', 'Authorized redirect URIs (required with --profile)') + .option('--logout-url ', 'Post-logout redirect URL') + .option('--authorized-origin-url ', 'Authorized origin URLs for the application; also added to the system CORS allowlist for --profile spa') + .option('--data ', 'Full application config as inline JSON or @file.json (mutually exclusive with --profile)') + .option('--application-id ', 'Application UUID (auto-generated if omitted; overrides --data)') + .option('--tenant-id ', 'Tenant UUID (overrides --data)') + .option('--yes', 'Skip confirmation prompt for automatic CORS configuration changes (spa profile)', false) + .addOption(apiKeyOption) + .addOption(hostOption) + .action(action); diff --git a/src/commands/application/get.ts b/src/commands/application/get.ts new file mode 100644 index 0000000..2334824 --- /dev/null +++ b/src/commands/application/get.ts @@ -0,0 +1,79 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, logEvent, errorAndExit, betaWarning } from '../../utils.js' +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { FusionAuthClient } from '@fusionauth/typescript-client'; + + +export async function executeGet(id: string, options: Record) { + logEvent("cli application:get") + const { + host = 'http://localhost:9011', + key, + output + } = options + + if (!host || !key) throw new Error("You must provide a FusionAuth host and an API key") + try { + const fusionAuthClient = new FusionAuthClient(key, host); + const res = await fusionAuthClient.retrieveApplication(id) + if (output) { + const fullPath = path.resolve(output); + await writeFileSync(fullPath, JSON.stringify(res.response, null, 2)) + return { + success: true, + fullPath + } + } else { + return { + success: true, + data: res.response + } + } + + } catch (e: any) { + if (e.statusCode === 404) return { + success: false, + error: "Application does not exist", + rawError: e + } + + return { + success: false, + error: `The server responded with an error: ${e.statusCode}`, + rawError: e + } + } +} + + +const action = async function (id: string, options: Record): Promise { + betaWarning(); + logEvent('cli application:get') + + const result = await executeGet(id, options) + + if (!result?.success) { + errorAndExit(result?.error ?? "An error ocurred while fetching the response."); + return; + } + if (result.fullPath) { + console.log(chalk.green(`Response written to `) + result.fullPath) + return + } + if (result.data) { + console.log(result.data) + } + +} + +export const appGet = new Command() + .command('application:get') + .argument('', "The FusionAuth Application ID to retrieve") + .option('-o, --output ', "Path where the data should be stored") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Retrieves an application by id, writing its data to a local file') + .action(action) diff --git a/src/commands/application/index.ts b/src/commands/application/index.ts new file mode 100644 index 0000000..e2004a3 --- /dev/null +++ b/src/commands/application/index.ts @@ -0,0 +1,2 @@ +export * from "./update.js"; +export * from "./get.js" \ No newline at end of file diff --git a/src/commands/application/update.ts b/src/commands/application/update.ts new file mode 100644 index 0000000..7246f08 --- /dev/null +++ b/src/commands/application/update.ts @@ -0,0 +1,148 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, betaWarning, errorAndExit, logEvent } from '../../utils.js' +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import chalk from "chalk"; +import { FusionAuthClient } from '@fusionauth/typescript-client'; +import fs from "node:fs" + +function parseData(data: string) { + let json: string; + if (data.startsWith('@')) { + const filePath = data.slice(1); + try { + json = fs.readFileSync(filePath, 'utf-8'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error reading --data file "${filePath}": ${message}`); + } + } else { + json = data; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Error parsing --data JSON: ${message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `--data JSON must be a non-null, non-array object, got ${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.` + ); + } + return parsed; +} + + + +export function setNestedProps(obj: any, path: string, value: any) { + /* Takes object and dynamically applies a property at any depth + myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} + */ + let schema = obj; + const pList = path.split('.'); + const len = pList.length; + for (var i = 0; i < len - 1; i++) { + var elem = pList[i]; + if (!schema[elem]) schema[elem] = {} + schema = schema[elem]; + } + + schema[pList[len - 1]] = value; + + return schema +} + + +function displaySuccess(message: string = "Successfully submitted Application update") { + console.log(chalk.green(message)) +} + +export function isJSON(string: string) { + try { + JSON.parse(string) + return true + } catch (e) { + return false + } +} + +export function splitProp(prop: string) { + try { + const [key, ...value] = prop.split("=") + const joinedValue = value.join("=") + if (isJSON(joinedValue)) { + return { key, value: JSON.parse(joinedValue) } + } + return { key, value: joinedValue } + } catch (e: any) { + throw new Error(e) + } +} + +export const executeUpdateAction = async function (id: string, options: Record) { + const { + host = 'http://localhost:9011', + key + } = options + const fusionAuthClient = new FusionAuthClient(key, host); + + try { + logEvent('cli application:update') + + if (!options.data && !options.prop) throw new Error("No --prop or --data was specified") + if (options?.data) { + + const data = await parseData(options.data) + const { response } = await fusionAuthClient.patchApplication(id, data) + return { + success: true, + patchData: data, + response + } + } + if (options?.prop) { + let data = { application: {} } + const splitprops = options.prop.map((prop: string) => { + if (!prop.includes('=')) throw new Error("Property string must be in the syntax =") + return splitProp(prop) + }) + splitprops.forEach((prop: any) => setNestedProps(data.application, prop.key, prop.value)) + const { response } = await fusionAuthClient.patchApplication(id, data) + return { + success: true, + patchData: data, + response + } + } + + } catch (e: any) { + if (e.statusCode === 404) throw new Error(`Application with ID ${id} does not exist`) + + const message = e instanceof Error ? e.message : String(e); + return { success: false, error: message, rawError: e }; + } +} + +const action = async (id: string, options: Record) => { + betaWarning() + const result = await executeUpdateAction(id, options) + if (!result?.success) { + errorAndExit(result?.error ?? 'Error updating application.', result?.rawError); + return; + } + displaySuccess(`Applied the following patch\n${JSON.stringify(result.patchData, null, 2)}`) + +} + +export const appUpdate = new Command() + .command('application:update') + .argument('', "The FusionAuth Application ID to update") + .option('--data ', "Full application config as inline JSON or @file.json") + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Updates an application with data provided via a file, a property, or a command flag.') + .action(action) diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 9ebecb7..6c741d7 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -1,4 +1,4 @@ -import {Command} from '@commander-js/extra-typings'; +import {Command, Option} from '@commander-js/extra-typings'; import {FusionAuthClient} from '@fusionauth/typescript-client'; import {readFile} from 'fs/promises'; import chalk from 'chalk'; @@ -7,6 +7,31 @@ import {errorAndExit, logEvent} from '../utils.js'; import { faker } from '@faker-js/faker'; import * as fs from 'fs'; +export const DEPRECATED_FLAGS: Record = { + '--numberOfFiles': '--number-of-files', + '--countPerFile': '--count-per-file', + '--applicationId': '--application-id', + '--groupId': '--group-id', + '--tmpDir': '--tmp-dir', + '--filePrefix': '--file-prefix', +}; + +// Exported for testability — pure function, no I/O; returns [oldFlag, replacement] pairs found in argv. +export function getDeprecatedFlagUsage(argv: string[]): Array<[string, string]> { + return Object.entries(DEPRECATED_FLAGS).filter(([old]) => + argv.some((arg) => arg === old || arg.startsWith(`${old}=`)) + ); +} + +function warnDeprecatedFlags(argv: string[] = process.argv): void { + for (const [old, replacement] of getDeprecatedFlagUsage(argv)) { + console.warn(chalk.yellow( + `DEPRECATION WARNING: please use ${replacement} going forward. ` + + `${old} will be removed in a future release.` + )); + } +} + const action = async function ({numberOfFiles, countPerFile, applicationId, groupId, tmpDir, filePrefix} : { numberOfFiles?: string | undefined; @@ -17,6 +42,8 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou filePrefix?: string | undefined; } ): Promise { + warnDeprecatedFlags(); + logEvent('cli command import:generate') console.log(`Generating users`); @@ -54,12 +81,19 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou // noinspection JSUnusedGlobalSymbols export const importGenerate = new Command('import:generate') .description('Generate sample import data') - .option('-n, --numberOfFiles ', 'The number of files.') - .option('-c, --countPerFile ', 'The count of records per file.') - .option('-a, --applicationId ', 'The application to register users to.') - .option('-g, --groupId ', 'The group id to add users to.') - .option('-d, --tmpDir ', 'The directory to write files to.', 'tmp') - .option('-f, --filePrefix ', 'The file prefix for output files.', 'output') + .option('-n, --number-of-files ', 'The number of files.') + .option('-c, --count-per-file ', 'The count of records per file.') + .option('-a, --application-id ', 'The application to register users to.') + .option('-g, --group-id ', 'The group id to add users to.') + .option('-d, --tmp-dir ', 'The directory to write files to.', 'tmp') + .option('-f, --file-prefix ', 'The file prefix for output files.', 'output') + // Deprecated camelCase aliases — hidden from help, kept for backward compatibility + .addOption(new Option('--numberOfFiles ', 'Deprecated: use --number-of-files').hideHelp()) + .addOption(new Option('--countPerFile ', 'Deprecated: use --count-per-file').hideHelp()) + .addOption(new Option('--applicationId ', 'Deprecated: use --application-id').hideHelp()) + .addOption(new Option('--groupId ', 'Deprecated: use --group-id').hideHelp()) + .addOption(new Option('--tmpDir ', 'Deprecated: use --tmp-dir').hideHelp()) + .addOption(new Option('--filePrefix ', 'Deprecated: use --file-prefix').hideHelp()) .action(action); diff --git a/src/commands/index.ts b/src/commands/index.ts index 33dd81d..3b9cc59 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,5 @@ +export * from './application/index.js' +export * from './application-create.js'; export * from './check-common-config.js'; export * from './check-oauth-2-1.js'; export * from './email-create.js'; diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 206797a..62004b2 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -8,11 +8,182 @@ import fs from 'node:fs' import path from "node:path"; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { betaWarning, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; +import { randomUUID } from 'node:crypto'; +import validator from 'validator'; +import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -async function createKickstart(kickstartPath: string, answers: any, newDir: string) { +/** + * Resolves the directory containing the kickstart resource files + * (fusionauth-config files, kickstart.json, etc.), supporting both layouts + * this file can run from: + * - Built (dist/): resources live beside the compiled command, at + * dist/commands/resources, via the build's copy-files step. + * - Source (src/, e.g. running this file directly via tsx during local + * development, independent of the npm start script): resources live one + * level up, at src/resources — they are not copied anywhere until a + * build runs. + * Throws if neither layout is found, rather than silently proceeding with + * a path that doesn't exist. + * + * @param baseDir Directory to resolve relative to. Defaults to this + * module's own directory (dist/commands or src/commands, depending on + * which was imported); overridable so tests can exercise all three + * outcomes (dist found / src fallback found / neither found) against + * controlled, synthetic directories instead of depending on the real + * repo's build state. + */ +export function resolveResourcesDir(baseDir: string = __dirname): string { + const distLayout = path.join(baseDir, 'resources'); + if (fs.existsSync(distLayout)) { + return distLayout; + } + const srcLayout = path.join(baseDir, '..', 'resources'); + if (fs.existsSync(srcLayout)) { + return srcLayout; + } + throw new Error(`Could not locate kickstart resources directory (checked ${distLayout} and ${srcLayout}).`); +} + +// --------------------------------------------------------------------------- +// Validation helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** + * Validates an email address. + * @returns `true` if valid, otherwise an error message string. + */ +export function validateEmail(email: string): true | string { + return validator.isEmail(email) ? true : 'Not a valid email address'; +} + +/** + * Validates the admin password. + * @returns `true` if valid, otherwise an error message string. + */ +export function validatePassword(password: string): true | string { + if (password.length === 0) { + return 'Custom password is required'; + } + if (password.length < 8) { + return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)'; + } + return true; +} + +// --------------------------------------------------------------------------- +// Answer resolution (exported for testing) +// --------------------------------------------------------------------------- + +export interface InstallOptions { + adminEmail?: string; + adminPasswordEnv?: string; + applicationName?: string; +} + +export interface InstallAnswers { + email: string; + password: string; + appName: string; +} + +/** + * We need the initial admin's credentials (email and password) and a name for a + * starter app. This will take values from command line params if present, then + * fall back to prompting the user. + * + * If all values are supplied then no prompts are shown. This is useful for + * unattended or agent-driven installs. + * + * Note that the password param names an environment variable to get the password + * from. This is to protect the password from showing up in process lists or being + * written to command line history files. + * + * @param options CLI option values (any subset may be provided). + * @param promptFn Injected prompt function; defaults to `inquirer.prompt`. + * Pass a mock in tests to avoid real TTY interaction. + */ +export async function resolveInstallAnswers( + options: InstallOptions, + promptFn: typeof inquirer.prompt = inquirer.prompt +): Promise { + // --- Resolve email --- + let email: string | undefined; + if (options.adminEmail !== undefined) { + const result = validateEmail(options.adminEmail); + if (result !== true) { + throw new Error(`--admin-email: ${result}`); + } + email = options.adminEmail; + } + + // --- Resolve password --- + let password: string | undefined; + if (options.adminPasswordEnv !== undefined) { + const envValue = process.env[options.adminPasswordEnv]; + if (envValue === undefined) { + throw new Error( + `--admin-password-env: environment variable "${options.adminPasswordEnv}" is not set` + ); + } + const result = validatePassword(envValue); + if (result !== true) { + throw new Error(`--admin-password-env: ${result}`); + } + password = envValue; + } + + // --- Resolve appName --- + let appName: string | undefined = options.applicationName; + + // --- Prompt for any fields not yet resolved --- + const questions: import('inquirer').DistinctQuestion[] = []; + + if (email === undefined) { + questions.push({ + type: 'input', + name: 'email', + message: 'Admin Email Address', + default: 'admin@example.com', + validate: validateEmail, + }); + } + + if (password === undefined) { + questions.push({ + type: 'password', + name: 'password', + message: 'Admin user password', + mask: true, + validate: validatePassword, + }); + } + + if (appName === undefined) { + questions.push({ + type: 'input', + name: 'appName', + message: 'Name your application', + default: 'Example App', + }); + } + + if (questions.length > 0) { + const prompted = await promptFn(questions); + if (email === undefined) email = prompted.email as string; + if (password === undefined) password = prompted.password as string; + if (appName === undefined) appName = prompted.appName as string; + } + + return { email: email!, password: password!, appName: appName! }; +} + +// --------------------------------------------------------------------------- +// Kickstart file generation +// --------------------------------------------------------------------------- + +async function createKickstart(kickstartPath: string, answers: InstallAnswers, newDir: string) { const salt = bcrypt.genSaltSync(10) const saltBase = salt.split('$10$')[1]; const fullHash = bcrypt.hashSync(answers.password, salt) @@ -29,11 +200,15 @@ async function createKickstart(kickstartPath: string, answers: any, newDir: stri fs.writeFileSync(`${newDir}/kickstart/kickstart.json`, JSON.stringify(kickstartObject, null, 2)) } -const action = async function (dir: string) { +// --------------------------------------------------------------------------- +// Command action +// --------------------------------------------------------------------------- + +const action = async function (dir: string, options: InstallOptions) { const dockerInstalled = isDockerInstalled(); const directory = path.resolve(dir) logEvent('cli command kickstart:install') - + betaWarning() try { @@ -53,81 +228,58 @@ const action = async function (dir: string) { console.error(chalk.red(`Can't write to ${parentDir}. Please check permissions on the directory`)) } - inquirer.prompt([ - { - type: 'input', - name: 'email', - message: "Admin Email Address", - default: 'admin@example.com', - validate: function (email) { - return /(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/.test(email) ? true : 'Not a valid email address'; - } - }, - { - type: 'password', - name: 'password', - message: "Admin user password", - mask: true, - validate: (text) => { - if (text.length == 0) { - return 'Custom password is required' - } else if (text.length < 8) { - return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)' - } else { - return true - } - } - }, - { - type: 'input', - name: 'appName', - message: 'Name your application', - default: "Example App" - } - ]) - .then((answers) => { - const spinner = yoctoSpinner({ text: "Building..." }).start() - setTimeout(() => { - // move fusionauth folder to user's project - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - }, 500) - setTimeout(() => { - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - }, 1500) - - setTimeout(() => { - const postgresPass = crypto.randomUUID() - const dbPass = crypto.randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - }, 2500) - - setTimeout(() => { - spinner.success("Done building!\n") - - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) - - }, 3500) - - }).catch((err) => { - console.error(chalk.yellow('Cancelling kickstart installation...')) - }) + let answers: InstallAnswers; + try { + answers = await resolveInstallAnswers(options); + } catch (e: any) { + errorAndExit(e.message ?? String(e)); + return; + } + + const spinner = yoctoSpinner({ text: "Building..." }).start() + try { + // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: + // - exceptions propagate through the surrounding try/catch + // - step ordering is deterministic regardless of machine speed + const resourcesDir = resolveResourcesDir(); + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${resourcesDir}/kickstart/fusionauth`, directory, { recursive: true }) + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exist.`)) + await createKickstart(resourcesDir + '/kickstart/kickstart.json', answers, directory) + + const postgresPass = randomUUID() + const dbPass = randomUUID() + + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + } catch (e) { + // Ensure the spinner's animation interval is stopped on every failure + // path — otherwise it keeps rendering (and can keep the process alive) + // even though the outer catch below has already taken over reporting. + spinner.error("Build failed.") + throw e + } } catch (e) { console.error(e) + // Ensure a failed install (copy, kickstart write, rename, env update, etc.) + // is reflected in the process exit code rather than silently exiting 0. + process.exitCode = 1 } - } export const kickstartInstall = new Command() .command('kickstart:install') .description('Adds a directory with a FusionAuth Docker + Kickstart') .argument('[dir]', 'Optional directory to install FusionAuth', 'fusionauth') - .action((dir) => action(dir)) \ No newline at end of file + .option('--admin-email ', 'Admin user email address (skips prompt)') + .option('--admin-password-env ', 'Name of environment variable containing the admin password (skips prompt)') + .option('--application-name ', 'Application name (skips prompt)') + .action((dir, options) => action(dir, options)) diff --git a/src/commands/kickstart-kill.ts b/src/commands/kickstart-kill.ts index 11e4863..4474ba6 100644 --- a/src/commands/kickstart-kill.ts +++ b/src/commands/kickstart-kill.ts @@ -2,57 +2,53 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import { spawn } from 'node:child_process'; -import { betaWarning, isDockerInstalled, logEvent } from "../utils.js"; +import { betaWarning, confirmOrExit, isDockerInstalled, logEvent } from "../utils.js"; import boxen from "boxen"; -import inquirer from "inquirer"; +// Dependencies below are injectable for testing — avoids real docker/confirm/exit calls +export interface KillDeps { + isDockerInstalled?: typeof isDockerInstalled; + confirmOrExit?: typeof confirmOrExit; + spawn?: typeof spawn; +} + +export const action = async function ({ yes }: { yes: boolean }, deps: KillDeps = {}) { + const checkDocker = deps.isDockerInstalled ?? isDockerInstalled; + const confirm = deps.confirmOrExit ?? confirmOrExit; + const spawnFn = deps.spawn ?? spawn; -const action = async function () { betaWarning(); try { - if (!isDockerInstalled()) throw (chalk.red('Error: You need Docker to run.')) - + if (!checkDocker()) throw (chalk.red('Error: You need Docker to run.')) + if (process.cwd() != process.env.CLI_DIR) throw(chalk.red('Error: Current directory was not kickstarted.')) logEvent('cli command kickstart:kill') - inquirer.prompt([ - { - type: 'confirm', - name: 'confirmation', - message: 'This is a destructive action. Are you sure you want to kill this container?' + await confirm( + "This will run 'docker compose down -v', destroying the container and all database data. This cannot be undone.", + yes + ); + console.log(chalk.yellow('Killing FusionAuth...\n')) + try { + const starting = spawnFn('docker compose down -v', { shell: true, stdio: 'inherit' }) + starting.on('error', e => { + console.error(e) + }) + if (starting?.stdout) { + for await (const data of starting.stdout) { + console.log(`${chalk.green(`FusionAuth:`)} ${data}`); + }; } - ]) - .then(async (answers) => { - if (!answers.confirmation) { - console.log(chalk.yellow('Cancelling the shutdown. The container is still running')) - process.exit() - } - - console.log(chalk.yellow('Killing FusionAuth...\n')) - try { - const starting = spawn('docker compose down -v', { shell: true, stdio: 'inherit' }) - starting.on('error', e => { - console.error(e) - }) - if (starting?.stdout) { - for await (const data of starting.stdout) { - console.log(`${chalk.green(`FusionAuth:`)} ${data}`); - }; - } - - starting.on('close', code => { - console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) - }) - } catch (e) { - console.error(e) - } - }).catch(e => { - console.log(chalk.red("The process exited. Please try again.")) + starting.on('close', code => { + console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) }) + } catch (e) { + console.error(e) + } } catch (err) { console.log(err) @@ -63,4 +59,5 @@ const action = async function () { export const kickstartKill = new Command() .command('kickstart:kill') .description('Runs docker compose down in current directory') - .action(action) + .option('--yes', 'Skip confirmation prompt', false) + .action((options) => action(options)) diff --git a/src/commands/telemetry/telemetry-utils.ts b/src/commands/telemetry/telemetry-utils.ts index d6515ae..76c4291 100644 --- a/src/commands/telemetry/telemetry-utils.ts +++ b/src/commands/telemetry/telemetry-utils.ts @@ -1,12 +1,10 @@ import fs from "node:fs" -import { __dirname } from '../../utils.js' - -import { loadConfig } from "../../utils.js" +import { getConfigDir, loadConfig } from "../../utils.js" export function telemetryUpdate(value: boolean) { let config = loadConfig() config.globalConfig.telemetry = value - fs.writeFileSync(__dirname + '/.fa/config.json', JSON.stringify(config.globalConfig, null, 2)) + fs.writeFileSync(getConfigDir() + '/.fa/config.json', JSON.stringify(config.globalConfig, null, 2)) return config } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7b0210f..54096a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ const authString = figlet.textSync('Auth').split('\n'); fusionString.forEach((line, i) => { console.log(chalk.white(line) + chalk.hex('#F58320')(authString[i])); }); + const program = new Command(); program.name('@fusionauth/cli').description('CLI for FusionAuth'); Object.values(commands).forEach((command) => { diff --git a/src/utils.ts b/src/utils.ts index 53f659d..7a62603 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ import fs, { readFileSync } from 'node:fs' import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; +import { inspect } from 'node:util'; import chalk from 'chalk'; import boxen from 'boxen'; @@ -11,7 +12,7 @@ import { PostHog } from 'posthog-node' import * as dotenv from 'dotenv' -dotenv.config() +dotenv.config({ quiet: true }); /** Shape of a FusionAuth ClientResponse — used for duck-type checking without importing internals. */ interface ClientResponseLike { @@ -42,7 +43,7 @@ export const isClientResponse = (response: any): response is ClientResponseLike * @param response */ export const isErrors = (response: any): response is Errors => { - return response.fieldErrors !== undefined || response.generalErrors !== undefined; + return response != null && (response.fieldErrors !== undefined || response.generalErrors !== undefined); } /** @@ -90,7 +91,14 @@ export const reportError = (msg: string, error?: any): void => { return; } - console.error(chalk.red(toJson(error))); + // Last resort for a shape that matched none of the above (e.g. a plain + // object with no message/fieldErrors/generalErrors). util.inspect, not + // JSON.stringify (toJson), is used here deliberately: it handles + // circular references and non-JSON-serializable values (functions, + // undefined, symbols) gracefully instead of throwing or silently + // dropping them, which matters since `error` here is of truly unknown + // shape by this point. + console.error(chalk.red(inspect(error, { depth: null }))); } /** @@ -180,6 +188,67 @@ export function errorAndExit(message: string, error?: any) { process.exit(1); } +// Exported for testability — pure logic, no I/O, easy to unit test directly. +export function isConfirmationAccepted(answer: string): boolean { + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + +// Exported for testability — settles the prompt's Promise without needing a real TTY/readline round-trip. +export function handleConfirmationAnswer( + answer: string, + resolve: () => void, + reject: (reason?: any) => void +): void { + if (isConfirmationAccepted(answer)) { + resolve(); + return; + } + console.log('Aborted.'); + process.exit(0); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — reject rather + // than falling through to resolve(), which would incorrectly treat a decline as + // confirmation. + reject(new Error('Aborted by user.')); +} + +/** + * Prompts the user for confirmation before proceeding with a risky operation. + * + * - If `yes` is true, returns immediately (caller has pre-confirmed). + * - If running interactively (both stdin and stdout are TTYs), prints the message + * and prompts [y/N]. Accepts "y" or "yes" (case-insensitive, whitespace trimmed) + * as confirmation; anything else aborts. + * - If not running interactively (agent/script/pipe — e.g. stdin is piped even if + * stdout is a TTY), prints the message and exits with an error instructing the + * caller to pass --yes. + * + * @param message A description of what will happen and why it is risky. + * @param yes The value of the --yes flag from the command options. + */ +export async function confirmOrExit(message: string, yes: boolean): Promise { + if (yes) return; + + console.warn(chalk.yellow(message)); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + errorAndExit('Pass --yes to confirm this operation non-interactively.'); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — throw rather + // than returning normally, which would incorrectly let the caller proceed. + throw new Error('Confirmation required: pass --yes to confirm this operation non-interactively.'); + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve, reject) => { + rl.question('Proceed? [y/N] ', (answer) => { + rl.close(); + handleConfirmationAnswer(answer, resolve, reject); + }); + }); +} + /** * Returns a console log that can be added to a beta feature to warn the user */ @@ -214,6 +283,16 @@ export function isDirEmpty(path: string) { } } +/** + * Returns the base directory used for the global `.fa/config.json` file. + * Defaults to the directory containing this module, but can be overridden + * via FUSIONAUTH_CONFIG_DIR — primarily so tests can point at a real + * temporary directory instead of mocking the filesystem. + */ +export function getConfigDir(): string { + return process.env.FUSIONAUTH_CONFIG_DIR ?? __dirname +} + export function loadConfig() { const defaultConfig = { telemetry: true, @@ -221,10 +300,11 @@ export function loadConfig() { version: "1.0" } - const configPath = __dirname + '/.fa/config.json' + const configDir = getConfigDir() + const configPath = configDir + '/.fa/config.json' try { if (!fs.existsSync(configPath)) { - createConfig(__dirname + '/.fa', defaultConfig) + createConfig(configDir + '/.fa', defaultConfig) } const globalConfig = JSON.parse(fs.readFileSync(configPath).toString()) // TODO: Combine this with a local-project config @@ -309,7 +389,7 @@ export function createConfig(dir: string, configObject: ConfigObject = { id: ran type PropertyToAdd = {[key:string]: any} async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd[]) { const config = loadConfig() - const configPath = __dirname + '/.fa/config.json' + const configPath = getConfigDir() + '/.fa/config.json' let newConfig: any; if (Array.isArray(propertiesToAdd)) { @@ -329,4 +409,199 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} + +export const exampleApplicationBody = { + "accessControlConfiguration": {}, + "active": true, + "authenticationTokenConfiguration": { + "enabled": false + }, + "data": {}, + "emailConfiguration": {}, + "externalIdentifierConfiguration": {}, + "formConfiguration": { + "adminRegistrationFormId": "UUID", + "selfServiceFormConfiguration": { + "requireCurrentPasswordOnPasswordChange": false + } + }, + "id": "UUID", + "insertInstant": 1234, + "jwtConfiguration": { + "accessTokenKeyId": "UUID", + "enabled": true, + "idTokenKeyId": "e73fe48a-1527-43cf-9b66-9eaa4c44d909", + "refreshTokenExpirationPolicy": "Fixed", + "refreshTokenOneTimeUseConfiguration": { + "gracePeriodInSeconds": 0 + }, + "refreshTokenSlidingWindowConfiguration": { + "maximumTimeToLiveInMinutes": 43200 + }, + "refreshTokenTimeToLiveInMinutes": 43200, + "refreshTokenUsagePolicy": "Reusable", + "timeToLiveInSeconds": 3600 + }, + "lambdaConfiguration": {}, + "lastUpdateInstant": 1789396293195, + "loginConfiguration": { + "allowTokenRefresh": false, + "generateRefreshTokens": false, + "requireAuthentication": true + }, + "multiFactorConfiguration": { + "email": {}, + "sms": {}, + "voice": {} + }, + "name": "Name string", + "oauthConfiguration": { + "authorizedOriginURLs": [ + "http://localhost:3000" + ], + "authorizedRedirectURLs": [ + "http://localhost:1002" + ], + "authorizedResourceUris": [ + "http://localhost:3000" + ], + "authorizedURLValidationPolicy": "ExactMatch", + "clientAuthenticationPolicy": "NotRequiredWhenUsingPKCE", + "clientId": "UUID", + "clientSecret": "super-secret-secret-that-should-be-regenerated-for-production", + "consentMode": "AlwaysPrompt", + "debug": true, + "enabledGrants": [ + "authorization_code", + "refresh_token" + ], + "generateRefreshTokens": true, + "logoutBehavior": "AllApplications", + "logoutURL": "http://localhost:3000", + "proofKeyForCodeExchangePolicy": "Required", + "providedScopePolicy": { + "address": { + "enabled": true, + "required": false + }, + "email": { + "enabled": true, + "required": false + }, + "phone": { + "enabled": true, + "required": false + }, + "profile": { + "enabled": true, + "required": false + } + }, + "relationship": "FirstParty", + "requireClientAuthentication": true, + "requireRegistration": true, + "scopeHandlingPolicy": "Strict", + "unknownScopePolicy": "Reject" + }, + "passwordlessConfiguration": { + "emailLoginStrategy": "ClickableLink", + "enabled": false, + "phoneLoginStrategy": "FormField" + }, + "phoneConfiguration": {}, + "registrationConfiguration": { + "birthDate": { + "enabled": false, + "required": false + }, + "completeRegistration": false, + "confirmPassword": false, + "enabled": true, + "firstName": { + "enabled": false, + "required": false + }, + "fullName": { + "enabled": false, + "required": false + }, + "lastName": { + "enabled": false, + "required": false + }, + "loginIdType": "email", + "middleName": { + "enabled": false, + "required": false + }, + "mobilePhone": { + "enabled": false, + "required": false + }, + "preferredLanguages": { + "enabled": false, + "required": false + }, + "type": "basic" + }, + "registrationDeletePolicy": { + "unverified": { + "enabled": false, + "numberOfDaysToRetain": 120 + } + }, + "roles": [], + "samlv2Configuration": { + "assertionEncryptionConfiguration": { + "digestAlgorithm": "SHA256", + "enabled": false, + "encryptionAlgorithm": "AES256GCM", + "keyLocation": "Child", + "keyTransportAlgorithm": "RSA_OAEP", + "maskGenerationFunction": "MGF1_SHA1" + }, + "authorizedRedirectURLs": [], + "debug": false, + "enabled": false, + "initiatedLogin": { + "enabled": false, + "nameIdFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }, + "loginHintConfiguration": { + "enabled": true, + "parameterName": "login_hint" + }, + "logout": { + "behavior": "AllParticipants", + "requireSignedRequests": false, + "singleLogout": { + "enabled": false, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "requireSignedRequests": false, + "xmlSignatureC14nMethod": "exclusive_with_comments", + "xmlSignatureLocation": "Assertion" + }, + "scopes": [], + "state": "Active", + "tenantId": "d7d09513-a3f5-401c-9685-34ab6c552453", + "universalConfiguration": { + "universal": false + }, + "unverified": { + "behavior": "Allow" + }, + "verifyRegistration": false, + "webAuthnConfiguration": { + "bootstrapWorkflow": { + "enabled": false + }, + "enabled": false, + "reauthenticationWorkflow": { + "enabled": false + } + } +}