diff --git a/AGENTS.md b/AGENTS.md index 8649667..3b6b715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,12 @@ -# FusionAuth CLI - Agent Guidelines +# FusionAuth CLI +FusionAuth CLI is a command-line tool for working with the FusionAuth CIAM platform. + +# Guidelines ## Build Commands - Build: `npm run build` (compiles TypeScript to `./dist/`) - No lint command configured -- No test framework - tests not implemented +- Tests use Node's built-in test runner (`node:test`); run `npm run test:unit` for fast unit tests or `npm test` for the full suite, including Docker-based integration tests under `__tests__/integration/`. ## Code Style Guidelines @@ -37,4 +40,4 @@ - Command definitions use Commander.js with fluent API - JSDoc comments for function documentation - Async/await for asynchronous operations -- Template literals for string interpolation \ No newline at end of file +- Template literals for string interpolation diff --git a/README.md b/README.md index 20cbbf6..53ba95f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ fusionauth --help; Currently, the CLI supports the following commands: - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. +- Applications + - `fusionauth application:create --name --profile --redirect-uri [--authorized-origin-url ]` - Create an application in one of a few pre-defined, standard security profiles (spa, native, or webapp), automatically configuring the associated OAuth/JWT settings (and CORS headers, for spa — native apps don't go through a browser's CORS enforcement, so this is skipped for native). `--authorized-origin-url` is required if the app will make cross-origin requests from the browser, since enabling CORS alone doesn't allow any origin through — the origin still needs to be added to the system's CORS allowlist. + - `fusionauth application:create [--name ] --data ` - Create an application from a full custom JSON configuration, for cases the standard profiles don't cover. The JSON's own `name` field is used unless `--name` is explicitly passed, in which case it overrides the JSON. - Emails - `fusionauth email:download` - Download a specific template or all email templates from a FusionAuth server. - `fusionauth email:duplicate` - Duplicate an email template locally. diff --git a/__tests__/commands/application-create.test.js b/__tests__/commands/application-create.test.js new file mode 100644 index 0000000..3af3b50 --- /dev/null +++ b/__tests__/commands/application-create.test.js @@ -0,0 +1,911 @@ +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('rawError is undefined for a direct, never-wrapped validation error', async () => { + // parseData() throws a plain Error directly (not via wrapError()), so + // it has no distinct .cause. Its message is already captured in + // `error` — if rawError also returned the same Error object, + // errorAndExit()/reportError() would print that message a second time. + const result = await executeApplicationCreate({ + ...BASE_OPTIONS, + data: '{not valid json', + }) + assert.equal(result.success, false) + assert.equal(result.rawError, undefined) + }) +}) + +// --------------------------------------------------------------------------- +// CORS header management +// --------------------------------------------------------------------------- + +describe('CORS header management', () => { + test('no PATCH when all required headers already present (any casing)', async () => { + // Only GET is registered — a PATCH would cause nock to throw + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + allowedHeaders: ['DPoP', 'authorization', 'ACCEPT', 'Content-Type'], + })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) + + test('PATCH adds only missing headers, preserving existing ones', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: true, + allowedHeaders: ['Authorization', 'Content-Type'], // dpop and Accept missing + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const headers = body.systemConfiguration.corsConfiguration.allowedHeaders + assert(headers.includes('Authorization'), 'should preserve existing Authorization') + assert(headers.includes('Content-Type'), 'should preserve existing Content-Type') + assert(headers.some(h => h.toLowerCase() === 'dpop'), 'should add dpop') + assert(headers.some(h => h.toLowerCase() === 'accept'), 'should add Accept') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH sets enabled:true when CORS is disabled', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ + enabled: false, + allowedHeaders: ['dpop', 'Authorization', 'Accept', 'Content-Type'], + })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + assert.equal(body.systemConfiguration.corsConfiguration.enabled, true) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + assert.equal(result.success, true) + }) + + test('PATCH adds --authorized-origin-url to the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://existing.example.com'] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + assert(origins.includes('https://existing.example.com'), 'should preserve existing origin') + assert(origins.includes('https://myapp.example.com'), 'should add the new origin') + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('duplicate --authorized-origin-url values are not duplicated in the system CORS allowlist', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration', (body) => { + const origins = body.systemConfiguration.corsConfiguration.allowedOrigins + const count = origins.filter(o => o === 'https://myapp.example.com').length + assert.equal(count, 1, `'https://myapp.example.com' should appear exactly once, got ${count}`) + return true + }) + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + yes: true, + // Same origin supplied twice via --authorized-origin-url. + authorizedOriginUrl: ['https://myapp.example.com', 'https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH when --authorized-origin-url is already in the system CORS allowlist', async () => { + // Headers/enabled already compliant too — only origins differ from the + // baseline, so this also exercises the "would otherwise early-return" + // path now correctly checking origins as well. + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['https://myapp.example.com'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('no PATCH for origins when allowedOrigins already contains "*"', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedOrigins: ['*'] })) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + assert.equal(result.success, true) + }) + + test('--authorized-origin-url is not required — no origin changes attempted when omitted', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + }) +}) + +// --------------------------------------------------------------------------- +// Confirmation gate (--yes) for CORS mutation +// Mutating system-wide CORS configuration must be gated behind +// confirmOrExit()/--yes. +// --------------------------------------------------------------------------- + +describe('confirmation gate for CORS mutation', () => { + test('non-interactive without --yes aborts before patching CORS or creating the application', async (t) => { + // process.exit is mocked so confirmOrExit() throws instead of actually + // exiting (see utils.ts docstring) — the throw is caught by + // executeApplicationCreate's try/catch and returned as a normal result. + // In production (unmocked), confirmOrExit() can still exit the process + // directly for a non-interactive caller without yes=true — see the + // documented exception to the "always returns a result" contract on + // executeApplicationCreate's JSDoc. It's only this test's mock that + // turns that exit into a returned result instead. + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) // missing headers → would trigger patch + + // Deliberately no PATCH or POST /api/application interceptors registered — + // if either were called, afterEach's nock.isDone() check would fail. + + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('--yes bypasses the confirmation prompt and proceeds with the CORS patch', async () => { + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse({ allowedHeaders: [] })) + + nock(FA_HOST) + .patch('/api/system-configuration') + .reply(200, {}) + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) + + const result = await executeApplicationCreate(spaOptions({ yes: true })) + + assert.equal(result.success, true) + }) + + test('a missing authorized origin alone (headers/enabled already compliant) still requires confirmation', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + nock(FA_HOST) + .get('/api/system-configuration') + .reply(200, systemConfigResponse()) // headers/enabled compliant; no allowedOrigins at all + + // Deliberately no PATCH or POST /api/application interceptors registered. + + const result = await executeApplicationCreate(spaOptions({ + authorizedOriginUrl: ['https://myapp.example.com'], + })) + + assert.equal(result.success, false) + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + }) +}) + +// --------------------------------------------------------------------------- +// Output — clientSecret presence/absence +// --------------------------------------------------------------------------- + +describe('output', () => { + test('clientSecret is returned for webapp profile', async () => { + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE_WITH_SECRET) + + const result = await executeApplicationCreate(webappOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, 'super-secret') + }) + + test('spa profile result includes name/applicationId/clientId and omits clientSecret', async () => { + mockCompliantSystemConfig() + + nock(FA_HOST) + .post('/api/application/') + .reply(200, APP_RESPONSE) // no clientSecret in response + + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true) + assert.equal(result.clientSecret, undefined) + assert.equal(result.name, 'Test App') + assert.equal(result.applicationId, APP_ID) + assert.equal(result.clientId, APP_ID) + }) +}) diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js index cf7ba13..ea24a7f 100644 --- a/__tests__/commands/kickstart-install.test.js +++ b/__tests__/commands/kickstart-install.test.js @@ -1,9 +1,14 @@ import { describe, test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { validateEmail, validatePassword, resolveInstallAnswers, + resolveResourcesDir, } from '../../src/commands/kickstart-install.js' // --------------------------------------------------------------------------- @@ -15,12 +20,21 @@ describe('validateEmail()', () => { assert.equal(validateEmail('admin@example.com'), true) }) + test('accepts an email with subdomain', () => { + assert.equal(validateEmail('user@mail.example.co.uk'), true) + }) + test('rejects an address with no @', () => { const result = validateEmail('notanemail') assert.notEqual(result, true) assert.match(result, /valid email/) }) + test('rejects an address with no domain', () => { + const result = validateEmail('user@') + assert.notEqual(result, true) + }) + test('rejects an empty string', () => { const result = validateEmail('') assert.notEqual(result, true) @@ -36,6 +50,10 @@ describe('validatePassword()', () => { assert.equal(validatePassword('abcdefgh'), true) }) + test('accepts a long password', () => { + assert.equal(validatePassword('supersecretpassword123'), true) + }) + test('rejects an empty password', () => { const result = validatePassword('') assert.notEqual(result, true) @@ -327,3 +345,108 @@ describe('resolveInstallAnswers() — inquirer validate functions', () => { assert.equal(passwordQuestion.validate, validatePassword) }) }) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() +// --------------------------------------------------------------------------- +// +// These use synthetic temp directories (via the injectable baseDir param) +// rather than the real repo layout, so all three logic branches are +// deterministically exercised regardless of whether a build has run — +// including the dist-layout branch, which this test file could never reach +// otherwise, since it always imports src/commands/kickstart-install.js via +// tsx, fixing __dirname to .../src/commands for the whole test run. + +describe('resolveResourcesDir()', () => { + const createdDirs = [] + + function mkTempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of createdDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns baseDir/resources when it exists (dist layout)', () => { + const baseDir = mkTempDir('resolve-resources-dist-') + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('falls back to baseDir/../resources when baseDir/resources is missing (src layout)', () => { + const parent = mkTempDir('resolve-resources-src-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + const expected = path.join(parent, 'resources') + fs.mkdirSync(expected) + // Deliberately no baseDir/resources — only the parent-level fallback exists. + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('prefers the dist layout when both exist', () => { + const parent = mkTempDir('resolve-resources-both-') + const baseDir = path.join(parent, 'commands') + fs.mkdirSync(baseDir) + fs.mkdirSync(path.join(parent, 'resources')) + const expected = path.join(baseDir, 'resources') + fs.mkdirSync(expected) + + assert.equal(resolveResourcesDir(baseDir), expected) + }) + + test('throws a clear error when neither layout exists', () => { + const baseDir = mkTempDir('resolve-resources-none-') + + assert.throws( + () => resolveResourcesDir(baseDir), + /Could not locate kickstart resources directory/ + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveResourcesDir() against the real built artifact (dist/) +// --------------------------------------------------------------------------- +// +// The tests above verify the function's logic in isolation; this verifies +// the actual distributable: that `npm run build`'s copy-files step really +// produces a dist/commands/resources directory the compiled command can +// find at its real __dirname, with the files kickstart:install needs. +// Skipped (not failed) when dist/ hasn't been built yet, so `test:unit` +// still works without requiring a build first — this is CI-meaningful +// since CI always runs `npm run build` before `npm test`. + +describe('resolveResourcesDir() against dist/ (built artifact)', () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const distModulePath = '../../dist/commands/kickstart-install.js' + const distModuleFile = path.join(__dirname, distModulePath) + + test('resolves dist/commands/resources from the compiled module', async (t) => { + if (!fs.existsSync(distModuleFile)) { + t.skip('dist/ has not been built — run `npm run build` first to exercise this test') + return + } + + const dist = await import(distModulePath) + const resourcesDir = dist.resolveResourcesDir() + + assert.equal(resourcesDir, path.join(path.dirname(distModuleFile), 'resources')) + assert.ok(fs.existsSync(resourcesDir), `${resourcesDir} should exist`) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'fusionauth')), + `${resourcesDir}/kickstart/fusionauth should exist` + ) + assert.ok( + fs.existsSync(path.join(resourcesDir, 'kickstart', 'kickstart.json')), + `${resourcesDir}/kickstart/kickstart.json should exist` + ) + }) +}) diff --git a/__tests__/integration/application-create/application-create.integration.test.js b/__tests__/integration/application-create/application-create.integration.test.js new file mode 100644 index 0000000..8527a64 --- /dev/null +++ b/__tests__/integration/application-create/application-create.integration.test.js @@ -0,0 +1,263 @@ +import { describe, test, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + startFusionAuthContainer, + stopFusionAuthContainer, + getApplication, + deleteApplication, + captureSystemConfigurationBaseline, + resetSystemConfiguration, + makeApiRequest, +} from '../setup.js' +import { executeApplicationCreate } from '../../../src/commands/application-create.js' + +const TENANT_ID = '886a57e0-f2ac-440a-9a9d-d10c17b6f1a1' +const REQUIRED_CORS_HEADERS = ['dpop', 'authorization', 'accept', 'content-type'] +const REDIRECT_URI = 'https://example.com/callback' + +describe('application:create integration tests', () => { + let fusionAuthUrl + let apiKey + let systemConfigBaseline + const createdApplicationIds = [] + + before(async () => { + const container = await startFusionAuthContainer() + fusionAuthUrl = container.url + apiKey = container.apiKey + systemConfigBaseline = await captureSystemConfigurationBaseline(apiKey) + }) + + after(async () => { + await stopFusionAuthContainer() + }) + + afterEach(async () => { + // Delete any applications created during the test + for (const id of createdApplicationIds.splice(0)) { + try { await deleteApplication(id, apiKey) } catch (_) {} + } + // Restore CORS to the captured baseline + await resetSystemConfiguration(apiKey) + }) + + function baseOptions(overrides = {}) { + return { + name: `Integration Test App ${Date.now()}`, + key: apiKey, + host: fusionAuthUrl, + tenantId: TENANT_ID, + // Bypasses the CORS-change confirmation prompt (spa profile). + // The confirmation gate itself is covered by unit tests; these + // integration tests are focused on real API behavior. + yes: true, + ...overrides, + } + } + + function spaOptions(overrides = {}) { + return baseOptions({ profile: 'spa', redirectUri: [REDIRECT_URI], ...overrides }) + } + + function webappOptions(overrides = {}) { + return baseOptions({ profile: 'webapp', redirectUri: [REDIRECT_URI], ...overrides }) + } + + // Verifies the required CORS headers (see REQUIRED_CORS_HEADERS) for the + // spa profile were added to system configuration, and that CORS is enabled. + async function assertCorsHeadersConfigured(apiKey) { + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = (sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? []) + .map(h => h.toLowerCase()) + for (const required of REQUIRED_CORS_HEADERS) { + assert(corsHeaders.includes(required), `CORS allowedHeaders should contain '${required}'`) + } + assert.equal(sysConfig.systemConfiguration.corsConfiguration?.enabled, true) + } + + // --------------------------------------------------------------------------- + // Happy paths — one per profile + // --------------------------------------------------------------------------- + + test('--profile spa creates application with correct settings and configures CORS', async () => { + const result = await executeApplicationCreate(spaOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.applicationId, 'applicationId should be set') + assert.ok(result.clientId, 'clientId should be set') + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + // Verify application settings were persisted correctly + const app = await getApplication(result.applicationId, apiKey) + assert.ok(app, 'application should exist in FusionAuth') + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireClientAuthentication, false) + assert.equal(app.oauthConfiguration.generateRefreshTokens, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.enabledGrants, ['authorization_code', 'refresh_token']) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, [REDIRECT_URI]) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + + // Verify CORS headers were added to system configuration + await assertCorsHeadersConfigured(apiKey) + }) + + test('--authorized-origin-url is added to the system CORS allowlist, not just the application', async () => { + const origin = 'https://myapp.example.com' + const result = await executeApplicationCreate(spaOptions({ authorizedOriginUrl: [origin] })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + + // The application-level setting (iframe/X-Frame-Options allowlist for + // hosted pages) is a separate concern from the system CORS allowlist + // below, but --authorized-origin-url should still populate it as before. + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual(app.oauthConfiguration.authorizedOriginURLs, [origin]) + + // The system-wide CORS allowlist must also include it, or the browser + // will block the spa's actual cross-origin requests to the API despite + // CORS being "configured" (headers/enabled only, per the bug this + // guards against). + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const allowedOrigins = sysConfig.systemConfiguration.corsConfiguration?.allowedOrigins ?? [] + assert(allowedOrigins.includes(origin), `system CORS allowedOrigins should contain '${origin}'`) + }) + + test('--profile native creates application without touching system CORS configuration', async () => { + const result = await executeApplicationCreate(baseOptions({ + profile: 'native', + redirectUri: ['myapp://callback'], + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + // Note: some FA versions generate a client secret even for public clients; + // what matters is that authentication is not REQUIRED (enforced below). + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'Required') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'NotRequired') + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.deepEqual(app.oauthConfiguration.authorizedRedirectURLs, ['myapp://callback']) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 300) + + // Native apps don't go through a browser's CORS enforcement, so + // --profile native should leave system CORS configuration untouched, + // unlike spa. + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + assert.deepEqual(sysConfig.systemConfiguration.corsConfiguration, systemConfigBaseline.corsConfiguration) + }) + + test('--profile webapp creates confidential client and returns clientSecret', async () => { + const result = await executeApplicationCreate(webappOptions()) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.ok(result.clientSecret, 'webapp should have a client secret') + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.equal(app.oauthConfiguration.proofKeyForCodeExchangePolicy, 'NotRequiredWhenUsingClientAuthentication') + assert.equal(app.oauthConfiguration.clientAuthenticationPolicy, 'Required') + assert.equal(app.oauthConfiguration.requireClientAuthentication, true) + assert.equal(app.oauthConfiguration.requireRegistration, true) + assert.equal(app.jwtConfiguration.timeToLiveInSeconds, 3600) + assert.equal(app.jwtConfiguration.refreshTokenUsagePolicy, 'OneTimeUse') + assert.equal(app.jwtConfiguration.refreshTokenExpirationPolicy, 'SlidingWindow') + }) + + test('--data custom mode creates application with provided configuration', async () => { + const customApp = { + oauthConfiguration: { + enabledGrants: ['authorization_code', 'refresh_token'], + authorizedRedirectURLs: ['https://custom.example.com/cb'], + generateRefreshTokens: true, + }, + } + + const result = await executeApplicationCreate(baseOptions({ + data: JSON.stringify(customApp), + })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + + createdApplicationIds.push(result.applicationId) + + const app = await getApplication(result.applicationId, apiKey) + assert.deepEqual( + app.oauthConfiguration.authorizedRedirectURLs, + ['https://custom.example.com/cb'] + ) + assert( + app.oauthConfiguration.enabledGrants.includes('authorization_code'), + 'should include authorization_code grant' + ) + }) + + // --------------------------------------------------------------------------- + // CORS idempotency + // --------------------------------------------------------------------------- + + test('running spa create twice does not duplicate CORS headers', async () => { + // First create + const result1 = await executeApplicationCreate(spaOptions()) + assert.equal(result1.success, true) + createdApplicationIds.push(result1.applicationId) + + // Second create without resetting CORS + const result2 = await executeApplicationCreate(spaOptions({ redirectUri: ['https://example.com/callback2'] })) + assert.equal(result2.success, true) + createdApplicationIds.push(result2.applicationId) + + const sysConfig = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + const corsHeaders = sysConfig.systemConfiguration.corsConfiguration?.allowedHeaders ?? [] + const corsHeadersLower = corsHeaders.map(h => h.toLowerCase()) + + // Each required header should appear exactly once + for (const required of REQUIRED_CORS_HEADERS) { + const count = corsHeadersLower.filter(h => h === required).length + assert.equal(count, 1, `'${required}' should appear exactly once in CORS allowedHeaders, got ${count}`) + } + }) + + // --------------------------------------------------------------------------- + // Regression: tenant header scoping (live server) + // Confirms /api/system-configuration actually accepts the request without + // the X-FusionAuth-TenantId header, which was the root cause of the 401. + // --------------------------------------------------------------------------- + + test('system-configuration is reachable without tenant header when --tenant-id is provided', async () => { + // If the tenant header were incorrectly sent to /api/system-configuration, + // this would fail with 401 — exactly the bug we fixed. + // (--tenant-id is already the default in baseOptions()/spaOptions().) + const result = await executeApplicationCreate(spaOptions()) + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + createdApplicationIds.push(result.applicationId) + }) + + // --------------------------------------------------------------------------- + // --application-id override + // --------------------------------------------------------------------------- + + test('--application-id is respected and application is created with that ID', async () => { + const customId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const result = await executeApplicationCreate(webappOptions({ applicationId: customId })) + + assert.equal(result.success, true, `Expected success but got: ${result.error}`) + assert.equal(result.applicationId, customId) + + createdApplicationIds.push(customId) + + const app = await getApplication(customId, apiKey) + assert.ok(app, 'application should exist with the specified ID') + assert.equal(app.id, customId) + }) +}) diff --git a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml index 2314490..7b96011 100644 --- a/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml +++ b/__tests__/integration/fixtures/kickstarts/fusionauth-integration-test-base/docker-compose.yml @@ -12,7 +12,6 @@ services: retries: 5 networks: - db_net - restart: unless-stopped volumes: - db_data:/var/lib/postgresql/data @@ -29,7 +28,6 @@ services: interval: 10s retries: 80 test: curl --write-out 'HTTP %{http_code}' --fail --silent --output /dev/null http://localhost:9200/ - restart: unless-stopped ulimits: memlock: soft: -1 @@ -68,7 +66,6 @@ services: networks: - db_net - search_net - restart: unless-stopped ports: - 9011:9011 volumes: diff --git a/__tests__/integration/setup.js b/__tests__/integration/setup.js index f27ae2e..7059e6b 100644 --- a/__tests__/integration/setup.js +++ b/__tests__/integration/setup.js @@ -2,6 +2,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' /** * Async version of exec used in place of execSync to avoid blocking the @@ -16,13 +17,138 @@ const execAsync = promisify(exec) * Handles docker compose lifecycle and FusionAuth readiness checks */ -const FUSIONAUTH_URL = 'http://localhost:9011' +const DEFAULT_FUSIONAUTH_URL = 'http://localhost:9011' const DEFAULT_API_KEY = '90dd6b25-d1ef-4175-9656-159dd994932e' -const HEALTH_CHECK_TIMEOUT = 120000 // 2 minutes +const HEALTH_CHECK_TIMEOUT = 240000 // 4 minutes const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const REQUEST_TIMEOUT = 10000 // 10 seconds +// fileURLToPath() (not .pathname) is required here: .pathname leaves +// characters like spaces percent-encoded (e.g. '%20'), which is not a +// valid path component on disk and would break both writeFileSync(envFile) +// and every docker compose invocation below for a checkout under a path +// containing a space. +const COMPOSE_DIR = fileURLToPath(new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url)) let isContainerRunning = false +let resolvedFusionAuthUrl = DEFAULT_FUSIONAUTH_URL + +/** + * Best-effort teardown used by the SIGINT/SIGTERM handlers below. Unlike + * stopFusionAuthContainer(), this does not check isContainerRunning — a + * termination signal can arrive before that flag is set (e.g. while still + * waiting on docker compose up -d or the health check loop), by which point + * containers may already exist and still need cleaning up. + * @param {string} reason - what triggered the teardown, for logging + */ +async function forceTeardown(reason) { + if (process.env.SKIP_TEARDOWN === 'true') { + console.log(`ℹ Skipping container teardown on ${reason} (SKIP_TEARDOWN=true)`) + return + } + try { + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + isContainerRunning = false + } catch (err) { + console.error(`Warning: Failed to stop container during ${reason} cleanup: ${err.message}`) + } +} + +let handlingTerminationSignal = false + +/** + * Ensures a Ctrl+C (or kill) during a test run doesn't leak the FusionAuth + * container — without this, after()/t.after() hooks never run on an + * interrupted process, leaving containers running (or stopped-but-not- + * removed, which can then collide with the next run's `docker compose up`). + * @param {string} signal + */ +async function handleTerminationSignal(signal) { + if (handlingTerminationSignal) { + // Second signal while teardown is still in flight (e.g. docker compose + // down -v hung) — the user wants out now. Exit immediately rather than + // silently no-op'ing: once a SIGINT/SIGTERM listener is registered, + // Node no longer applies its default "second Ctrl+C just kills the + // process" behavior on its own, so we have to implement that ourselves. + console.log(`\n⚠ Received ${signal} again — forcing immediate exit (teardown may be incomplete).`) + process.exit(signal === 'SIGINT' ? 130 : 143) + } + handlingTerminationSignal = true + console.log(`\n⚠ Received ${signal}, cleaning up FusionAuth container before exiting...`) + await forceTeardown(signal) + process.exit(signal === 'SIGINT' ? 130 : 143) +} + +process.on('SIGINT', () => { void handleTerminationSignal('SIGINT') }) +process.on('SIGTERM', () => { void handleTerminationSignal('SIGTERM') }) + +/** + * Resolves the FusionAuth service container's ID via Docker Compose, + * rather than assuming Compose's default generated container name + * ('{project}-{service}-{index}'). That default only holds when + * COMPOSE_PROJECT_NAME is unset; if it's set (e.g. by a contributor's + * shell profile or CI wrapper), the real container name differs and a + * hard-coded guess would silently fail to match anything. + * @returns {Promise} The container ID, or '' if it can't be found + * (e.g. the compose project doesn't exist yet) — callers should treat + * that the same as a failed `docker inspect` and fall back gracefully. + */ +async function resolveContainerId() { + try { + const { stdout } = await execAsync('docker compose --env-file .env.test ps -q fusionauth', { cwd: COMPOSE_DIR }) + return stdout.trim() + } catch (_) { + return '' + } +} + +/** + * Resolves the FusionAuth URL. On environments where localhost port-mapping + * behaves differently (e.g. macOS Docker Desktop), falls back to the + * container's direct bridge IP to ensure authenticated requests succeed. + * @returns {Promise} + */ +async function resolveFusionAuthUrl() { + // First try localhost — if an authenticated request succeeds, use it. + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`${DEFAULT_FUSIONAUTH_URL}/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) return DEFAULT_FUSIONAUTH_URL + } catch (_) {} + + // Fall back to the container's direct bridge IP (works on macOS Docker Desktop + // where localhost port-mapping doesn't forward API-key auth correctly). + try { + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ips = stdout.trim().split(/\s+/).filter(Boolean) + for (const ip of ips) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) + const response = await fetch(`http://${ip}:9011/api/tenant`, { + headers: { Authorization: DEFAULT_API_KEY }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (response.ok) { + console.log(`ℹ Using container IP ${ip}:9011 (localhost port-mapping not compatible)`) + return `http://${ip}:9011` + } + } catch (_) {} + } + } catch (_) {} + + // Return localhost as a last resort — health check will catch startup failures. + return DEFAULT_FUSIONAUTH_URL +} /** * Start FusionAuth via docker compose @@ -31,14 +157,14 @@ let isContainerRunning = false export async function startFusionAuthContainer() { if (isContainerRunning || process.env.REUSE_CONTAINER === 'true') { console.log('ℹ Using existing FusionAuth container') - return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + return { url: resolvedFusionAuthUrl, apiKey: DEFAULT_API_KEY } } console.log('↻ Starting FusionAuth container via docker compose...') - const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname - const envFile = path.join(composeDir, '.env.test') - const kickstartFilePath = path.join(composeDir, 'kickstart.json') + const envFile = path.join(COMPOSE_DIR, '.env.test') + const kickstartFilePath = path.join(COMPOSE_DIR, 'kickstart.json') // Create .env.test file with test configuration const envContent = ` @@ -57,28 +183,44 @@ OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m fs.writeFileSync(envFile, envContent) try { - // Check for and tear down any existing containers first + // Check for and tear down any existing containers first. Use -a/--all — + // without it, docker compose ps only lists running/restarting + // containers, so a stopped-but-not-removed container from a prior + // interrupted run would be invisible here, this cleanup would be + // skipped entirely, and the `up -d` below would fail with + // "Conflict: container name already in use". + let psOutput = '' try { - const { stdout: psOutput } = await execAsync(`cd ${composeDir} && docker compose ps -q`) - if (psOutput.trim()) { - console.log('⚠ Found existing FusionAuth containers, tearing them down...') - await execAsync(`cd ${composeDir} && docker compose down -v`) - console.log('✓ Existing containers removed') - } + const result = await execAsync('docker compose --env-file .env.test ps -aq', { cwd: COMPOSE_DIR }) + psOutput = result.stdout } catch (e) { - // Container may not exist, that's fine + // `docker compose ps` itself failing (e.g. project has never existed) + // is fine — there's nothing to tear down. + } + + if (psOutput.trim()) { + console.log('⚠ Found existing FusionAuth containers, tearing them down...') + // Unlike the ps check above, a failure here means stale containers + // genuinely remain. Let it propagate (via the outer catch) instead of + // silently continuing into `up -d`, which would just hit the same + // naming conflict with a far more confusing error message. + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) + console.log('✓ Existing containers removed') } // Start containers - await execAsync(`cd ${composeDir} && docker compose --env-file .env.test up -d`) + await execAsync('docker compose --env-file .env.test up -d', { cwd: COMPOSE_DIR }) // Wait for FusionAuth to be healthy await waitForFusionAuthReady() + // Resolve the URL that actually works for authenticated requests + resolvedFusionAuthUrl = await resolveFusionAuthUrl() + isContainerRunning = true console.log('✓ FusionAuth container started and ready') - return { url: FUSIONAUTH_URL, apiKey: DEFAULT_API_KEY } + return { url: resolvedFusionAuthUrl, apiKey: DEFAULT_API_KEY } } catch (err) { throw new Error(`Failed to start FusionAuth container: ${err.message}`) } @@ -101,10 +243,8 @@ export async function stopFusionAuthContainer() { console.log('↻ Stopping FusionAuth container...') - const composeDir = new URL('./fixtures/kickstarts/fusionauth-integration-test-base', import.meta.url).pathname - try { - await execAsync(`cd ${composeDir} && docker compose down -v`) + await execAsync('docker compose --env-file .env.test down -v', { cwd: COMPOSE_DIR }) isContainerRunning = false console.log('✓ FusionAuth container stopped') } catch (err) { @@ -124,36 +264,61 @@ async function waitForFusionAuthReady() { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - const response = await fetch(`${FUSIONAUTH_URL}/api/status`, { + const response = await fetch(`${DEFAULT_FUSIONAUTH_URL}/api/status`, { signal: controller.signal }) clearTimeout(timeoutId) if (response.ok) { - // Verify authenticated API requests work by fetching tenants, there was a problem with the status returning OK but the Key did not work + // Verify the kickstart has run and the container is fully initialized + // by checking authenticated API access. Tries localhost first, then + // falls back to the container's direct bridge IP. let authReady = false const authStartTime = Date.now() while (Date.now() - authStartTime < 30000) { // 30 second timeout for auth readiness + // Try localhost first, only falling back to the container's direct + // bridge IP (via docker inspect) if localhost doesn't respond ok. + // Mirrors resolveFusionAuthUrl()'s ordering — on Docker Desktop the + // bridge IP generally isn't routable from the host, so localhost + // must be attempted first rather than being unconditionally + // overridden. + const urlsToTry = [DEFAULT_FUSIONAUTH_URL] try { - const authController = new AbortController() - const authTimeoutId = setTimeout(() => authController.abort(), 5000) - - const tenantsResponse = await fetch(`${FUSIONAUTH_URL}/api/tenant`, { - method: 'GET', - headers: { Authorization: DEFAULT_API_KEY }, - signal: authController.signal - }) - clearTimeout(authTimeoutId) - - if (tenantsResponse.ok) { - authReady = true - break + const containerId = await resolveContainerId() + if (!containerId) throw new Error('FusionAuth container not found') + const { stdout } = await execAsync( + `docker inspect ${containerId} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` + ) + const ip = stdout.trim().split(/\s+/).filter(Boolean)[0] + if (ip) urlsToTry.push(`http://${ip}:9011`) + } catch (_) {} + + for (const checkUrl of urlsToTry) { + try { + const authController = new AbortController() + const authTimeoutId = setTimeout(() => authController.abort(), 5000) + + const tenantsResponse = await fetch(`${checkUrl}/api/tenant`, { + method: 'GET', + headers: { Authorization: DEFAULT_API_KEY }, + signal: authController.signal + }) + clearTimeout(authTimeoutId) + + if (tenantsResponse.ok) { + authReady = true + break + } + } catch (err) { + // Not reachable via this URL yet, try the next one } - } catch (err) { - // Auth not ready yet, retry } - + + if (authReady) { + break + } + await sleep(HEALTH_CHECK_INTERVAL) } @@ -182,7 +347,7 @@ async function waitForFusionAuthReady() { * @returns {Promise} */ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT_API_KEY) { - const url = `${FUSIONAUTH_URL}${path}` + const url = `${resolvedFusionAuthUrl}${path}` const headers = { Authorization: apiKey, 'Content-Type': 'application/json' @@ -212,7 +377,15 @@ export async function makeApiRequest(method, path, data = null, apiKey = DEFAULT ) } - return await response.json() + // Some successful responses (e.g. DELETE /api/application) have an + // empty body — calling response.json() directly throws in that case + // ("Unexpected end of JSON input"), so read as text first and only + // parse when there's actually something to parse. + const responseText = await response.text() + if (!responseText) { + return null + } + return JSON.parse(responseText) } catch (err) { if (err.name === 'AbortError') { throw new Error(`API request timeout: ${method} ${path}`) @@ -269,6 +442,57 @@ export async function getMessageTemplateByName(name, apiKey = DEFAULT_API_KEY) { return templates.find(t => t.name === name) } +/** + * Get application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function getApplication(applicationId, apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', `/api/application/${applicationId}`, null, apiKey) + return data.application +} + +/** + * Delete application by ID from FusionAuth + * @param {string} applicationId - Application ID + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function deleteApplication(applicationId, apiKey = DEFAULT_API_KEY) { + // First deactivate, then hard-delete + await makeApiRequest('DELETE', `/api/application/${applicationId}`, null, apiKey) + await makeApiRequest('DELETE', `/api/application/${applicationId}?hardDelete=true`, null, apiKey) +} + +let baselineSystemConfiguration = null + +/** + * Captures the current system configuration as the baseline to restore to + * after CORS-mutating tests. Must be called once before any test that + * modifies system configuration (e.g. application:create --profile spa). + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function captureSystemConfigurationBaseline(apiKey = DEFAULT_API_KEY) { + const data = await makeApiRequest('GET', '/api/system-configuration', null, apiKey) + baselineSystemConfiguration = data.systemConfiguration + return baselineSystemConfiguration +} + +/** + * Restores system configuration to the captured baseline. Uses PUT (full + * overwrite) rather than PATCH so the restore is exact, not merged. + * @param {string} apiKey - API key + * @returns {Promise} + */ +export async function resetSystemConfiguration(apiKey = DEFAULT_API_KEY) { + if (!baselineSystemConfiguration) { + throw new Error('captureSystemConfigurationBaseline() must be called before resetSystemConfiguration()') + } + await makeApiRequest('PUT', '/api/system-configuration', { systemConfiguration: baselineSystemConfiguration }, apiKey) +} + /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep diff --git a/__tests__/telemetry/telemetry.test.js b/__tests__/telemetry/telemetry.test.js index 8de09df..798c9b8 100644 --- a/__tests__/telemetry/telemetry.test.js +++ b/__tests__/telemetry/telemetry.test.js @@ -99,6 +99,14 @@ describe('tests for logEvent', () => { beforeEach(() => { tempDir = createTempDir() process.env.FUSIONAUTH_CONFIG_DIR = tempDir + // This block's tests assert on the presence/absence of + // FUSIONAUTH_TELEMETRY, including a test that requires it to be + // completely unset. Running the suite with FUSIONAUTH_TELEMETRY=false + // (as npm run test:unit does, to keep logEvent() from making real + // network/filesystem calls in *other* test files) would otherwise + // leak into these tests depending on execution order. Start every + // test here from a known, unset baseline instead of relying on that. + delete process.env.FUSIONAUTH_TELEMETRY }) afterEach(() => { diff --git a/package-lock.json b/package-lock.json index fd8ea8c..b0cbbbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 77ba0b0..4b3dbad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "description": "FusionAuth CLI", "main": "dist/index.js", "engines": { @@ -16,7 +16,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "npm run build && node dist/index.js", - "test": "node --import tsx --test", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test --test-concurrency=1 '__tests__/integration/**/*.test.js'", + "test:unit": "NODE_ENV=test FUSIONAUTH_TELEMETRY=false node --import=tsx --test __tests__/utils.test.js '__tests__/postInstall/*.test.js' '__tests__/telemetry/*.test.js' '__tests__/utilities/**/*.test.js' '__tests__/commands/*.test.js'", "prepare": "husky" }, "keywords": [ diff --git a/src/commands/application-create.ts b/src/commands/application-create.ts new file mode 100644 index 0000000..b47b266 --- /dev/null +++ b/src/commands/application-create.ts @@ -0,0 +1,498 @@ +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 instanceof Error ? e.message : String(e)}`, 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 instanceof Error ? e.message : String(e)}`, 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 instanceof Error ? e.message : String(e)}`, 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) { + const result = await executeApplicationCreate(options); + + if (!result.success) { + utils.errorAndExit(result.error ?? 'Error creating application.', result.rawError); + return; + } + + console.log(chalk.green('Application created.')); + console.log(` Name: ${result.name}`); + console.log(` Application ID / client_id: ${result.clientId}`); + if (result.clientSecret) { + console.log(` Client Secret: ${result.clientSecret}`); + } + + console.log(boxen( + [ + `Customize FusionAuth with a ${chalk.cyan('simple theme')}:`, + ' https://fusionauth.io/docs/customize/look-and-feel/simple-theme-editor', + '', + `Create ${chalk.cyan('users')}:`, + ' https://fusionauth.io/docs/lifecycle/register-users/', + '', + `Configure an ${chalk.cyan('SMTP server')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/configure-email', + '', + `Set up ${chalk.cyan('email templates')}:`, + ' https://fusionauth.io/docs/customize/email-and-messages/email-templates', + '', + `${chalk.cyan('Add login')} to your application:`, + ' https://fusionauth.io/docs/get-started/start-here/step-1', + '', + ].join('\n'), + {padding: 1, title: 'Next Steps', borderColor: 'green', borderStyle: 'bold'} + )); +}; + +// noinspection JSUnusedGlobalSymbols +export const applicationCreate = new Command('application:create') + .description('Create an application in FusionAuth') + .option('--name ', 'The name of the application (required with --profile; overrides the name in --data if provided)') + .addOption( + new Option('--profile ', 'Security profile to apply (mutually exclusive with --data)') + .choices(['spa', 'native', 'webapp'] as const) + ) + .option('--redirect-uri ', 'Authorized redirect URIs (required with --profile)') + .option('--logout-url ', 'Post-logout redirect URL') + .option('--authorized-origin-url ', 'Authorized origin URLs for the application; also added to the system CORS allowlist for --profile spa') + .option('--data ', 'Full application config as inline JSON or @file.json (mutually exclusive with --profile)') + .option('--application-id ', 'Application UUID (auto-generated if omitted; overrides --data)') + .option('--tenant-id ', 'Tenant UUID (overrides --data)') + .option('--yes', 'Skip confirmation prompt for automatic CORS configuration changes (spa profile)', false) + .addOption(apiKeyOption) + .addOption(hostOption) + .action(action); diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index 75de421..6c741d7 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -27,7 +27,7 @@ function warnDeprecatedFlags(argv: string[] = process.argv): void { for (const [old, replacement] of getDeprecatedFlagUsage(argv)) { console.warn(chalk.yellow( `DEPRECATION WARNING: please use ${replacement} going forward. ` + - `${old} will be deprecated in a future release.` + `${old} will be removed in a future release.` )); } } diff --git a/src/commands/index.ts b/src/commands/index.ts index 07f0f21..94714ef 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from './application-create.js'; export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 52aaf8c..62004b2 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -14,6 +14,38 @@ import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } fr const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * Resolves the directory containing the kickstart resource files + * (fusionauth-config files, kickstart.json, etc.), supporting both layouts + * this file can run from: + * - Built (dist/): resources live beside the compiled command, at + * dist/commands/resources, via the build's copy-files step. + * - Source (src/, e.g. running this file directly via tsx during local + * development, independent of the npm start script): resources live one + * level up, at src/resources — they are not copied anywhere until a + * build runs. + * Throws if neither layout is found, rather than silently proceeding with + * a path that doesn't exist. + * + * @param baseDir Directory to resolve relative to. Defaults to this + * module's own directory (dist/commands or src/commands, depending on + * which was imported); overridable so tests can exercise all three + * outcomes (dist found / src fallback found / neither found) against + * controlled, synthetic directories instead of depending on the real + * repo's build state. + */ +export function resolveResourcesDir(baseDir: string = __dirname): string { + const distLayout = path.join(baseDir, 'resources'); + if (fs.existsSync(distLayout)) { + return distLayout; + } + const srcLayout = path.join(baseDir, '..', 'resources'); + if (fs.existsSync(srcLayout)) { + return srcLayout; + } + throw new Error(`Could not locate kickstart resources directory (checked ${distLayout} and ${srcLayout}).`); +} + // --------------------------------------------------------------------------- // Validation helpers (exported for testing) // --------------------------------------------------------------------------- @@ -206,25 +238,34 @@ const action = async function (dir: string, options: InstallOptions) { const spinner = yoctoSpinner({ text: "Building..." }).start() - // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: - // - exceptions propagate through the surrounding try/catch - // - step ordering is deterministic regardless of machine speed - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - - const postgresPass = randomUUID() - const dbPass = randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - - spinner.success("Done building!\n") - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + try { + // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: + // - exceptions propagate through the surrounding try/catch + // - step ordering is deterministic regardless of machine speed + const resourcesDir = resolveResourcesDir(); + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${resourcesDir}/kickstart/fusionauth`, directory, { recursive: true }) + + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exist.`)) + await createKickstart(resourcesDir + '/kickstart/kickstart.json', answers, directory) + + const postgresPass = randomUUID() + const dbPass = randomUUID() + + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + } catch (e) { + // Ensure the spinner's animation interval is stopped on every failure + // path — otherwise it keeps rendering (and can keep the process alive) + // even though the outer catch below has already taken over reporting. + spinner.error("Build failed.") + throw e + } } catch (e) { console.error(e) diff --git a/src/index.ts b/src/index.ts index 7b0210f..54096a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ const authString = figlet.textSync('Auth').split('\n'); fusionString.forEach((line, i) => { console.log(chalk.white(line) + chalk.hex('#F58320')(authString[i])); }); + const program = new Command(); program.name('@fusionauth/cli').description('CLI for FusionAuth'); Object.values(commands).forEach((command) => {