diff --git a/src/context/directory/handlers/connections.ts b/src/context/directory/handlers/connections.ts index 518b85e49..268759dcf 100644 --- a/src/context/directory/handlers/connections.ts +++ b/src/context/directory/handlers/connections.ts @@ -75,6 +75,12 @@ async function dump(context: DirectoryContext): Promise { ); } + // Filter to included connections + const includedConnections = (context.assets.include && context.assets.include.connections) || []; + if (includedConnections.length) { + connections = connections.filter((connection) => includedConnections.includes(connection.name)); + } + const connectionsFolder = path.join(context.filePath, constants.CONNECTIONS_DIRECTORY); fs.ensureDirSync(connectionsFolder); @@ -131,11 +137,23 @@ async function dump(context: DirectoryContext): Promise { if (dumpedConnection.strategy === 'email') expectedFiles.add(`${connectionName}.html`); }); + // With an include list configured, connections outside it are unmanaged, so limit pruning + // to the listed names rather than every file in the folder. + const prunableFiles = includedConnections.length + ? new Set( + includedConnections.flatMap((name) => [`${sanitize(name)}.json`, `${sanitize(name)}.html`]) + ) + : null; + // Remove files that belong to connections no longer present (and not excluded) if (fs.existsSync(connectionsFolder)) { for (const existing of fs.readdirSync(connectionsFolder)) { const fullPath = path.join(connectionsFolder, existing); - if (fs.statSync(fullPath).isFile() && !expectedFiles.has(existing)) { + if ( + fs.statSync(fullPath).isFile() && + !expectedFiles.has(existing) && + (prunableFiles === null || prunableFiles.has(existing)) + ) { fs.removeSync(fullPath); } } diff --git a/src/context/yaml/handlers/connections.ts b/src/context/yaml/handlers/connections.ts index 33dddfe04..cb7483592 100644 --- a/src/context/yaml/handlers/connections.ts +++ b/src/context/yaml/handlers/connections.ts @@ -61,6 +61,12 @@ async function dump(context: YAMLContext): Promise { ); } + // Filter to included connections + const includedConnections = (context.assets.include && context.assets.include.connections) || []; + if (includedConnections.length) { + connections = connections.filter((connection) => includedConnections.includes(connection.name)); + } + return { connections: connections.map((connection) => { let dumpedConnection = { diff --git a/test/context/directory/connections.test.js b/test/context/directory/connections.test.js index af405d289..9a2c2d4e8 100644 --- a/test/context/directory/connections.test.js +++ b/test/context/directory/connections.test.js @@ -266,6 +266,69 @@ describe('#directory context connections', () => { expect(fs.existsSync(path.join(connectionsFolder, 'excludedConnection.json'))).to.equal(true); }); + it('should only dump included connections', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + await handler.dump(context); + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + + expect(fs.existsSync(path.join(connectionsFolder, 'includedConnection.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'unmanagedConnection.json'))).to.equal(false); + }); + + it('should preserve pre-existing files for connections outside the include list on re-dump', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + + // Simulate a prior export that wrote a connection now outside the include list + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + fs.ensureDirSync(connectionsFolder); + fs.writeFileSync( + path.join(connectionsFolder, 'unmanagedConnection.json'), + '{"name":"unmanagedConnection"}' + ); + + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + await handler.dump(context); + + expect(fs.existsSync(path.join(connectionsFolder, 'includedConnection.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'unmanagedConnection.json'))).to.equal(true); + }); + + it('should remove stale files for included connections no longer present on the tenant', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsIncludeStaleFiles'); + cleanThenMkdir(dir); + + // Simulate a previous export of two included connections, one since deleted on the tenant + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + fs.ensureDirSync(connectionsFolder); + fs.writeFileSync(path.join(connectionsFolder, 'facebook.json'), '{"name":"facebook"}'); + fs.writeFileSync(path.join(connectionsFolder, 'github.json'), '{"name":"github"}'); + + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + context.assets.connections = [{ name: 'github', strategy: 'github' }]; + context.assets.include = { connections: ['facebook', 'github'] }; + + await handler.dump(context); + + expect(fs.existsSync(path.join(connectionsFolder, 'github.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'facebook.json'))).to.equal(false); + }); + it('should remove stale connection files when connections are removed from source', async () => { const dir = path.join(testDataDir, 'directory', 'connectionsStaleFiles'); cleanThenMkdir(dir); diff --git a/test/context/yaml/connections.test.js b/test/context/yaml/connections.test.js index a8caa038e..18986496d 100644 --- a/test/context/yaml/connections.test.js +++ b/test/context/yaml/connections.test.js @@ -276,4 +276,24 @@ describe('#YAML context connections', () => { expect(dumped.connections).to.have.length(1); expect(dumped.connections[0].name).to.equal('includedConnection'); }); + + it('should only dump included connections', async () => { + const dir = path.join(testDataDir, 'yaml', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + const context = new Context( + { AUTH0_INPUT_FILE: path.join(dir, 'tenant.yaml') }, + mockMgmtClient() + ); + + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + const dumped = await handler.dump(context); + + expect(dumped.connections).to.have.length(1); + expect(dumped.connections[0].name).to.equal('includedConnection'); + }); });