diff --git a/lib/DefaultOptions.ts b/lib/DefaultOptions.ts index 596db49..6918a6f 100644 --- a/lib/DefaultOptions.ts +++ b/lib/DefaultOptions.ts @@ -15,17 +15,25 @@ export class DefaultOptions extends Options { name: 'user', flag: 'u', description: 'Username for checking all confluence documents', - required: true, + required: false, }) - confluenceUser: string + confluenceUser = '' @option({ name: 'password', flag: 'p', description: 'Password for the user', - required: true, + required: false, + }) + confluencePassword = '' + + @option({ + name: 'token', + flag: 't', + description: 'Personal Access Token for the user. If set user and password will be ignored', + required: false, }) - confluencePassword: string + confluencePersonalAccessToken = '' @option({ description: 'Log-Level to use (trace, debug, verbose, info, warn, error)', diff --git a/lib/api/Configuration.ts b/lib/api/Configuration.ts index 8eb4665..5ca4756 100644 --- a/lib/api/Configuration.ts +++ b/lib/api/Configuration.ts @@ -34,6 +34,11 @@ export class Configuration { */ public confluencePassword: string + /** + * The personal access token of the Confluence user + */ + public confluencePersonalAccessToken: string + /** * The document id of the configuration document */ @@ -94,10 +99,17 @@ export class Configuration { */ private _log: Logger - constructor(confluenceUrl: string, confluenceUser: string, confluencePassword: string, configurationDocumentId: string) { + constructor( + confluenceUrl: string, + confluenceUser: string, + confluencePassword: string, + confluencePersonalAccessToken: string, + configurationDocumentId: string + ) { this.confluenceUrl = confluenceUrl this.confluenceUser = confluenceUser this.confluencePassword = confluencePassword + this.confluencePersonalAccessToken = confluencePersonalAccessToken this.configurationDocumentId = configurationDocumentId this._loaded = false @@ -134,11 +146,20 @@ export class Configuration { // eslint-disable-next-line @typescript-eslint/no-explicit-any let configurationDocument: any try { - configurationDocument = await got(configurationUrl, { - username: this.confluenceUser, - password: this.confluencePassword, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }).json() + if (this.confluencePersonalAccessToken !== '') { + configurationDocument = await got(configurationUrl, { + headers: { + Authorization: 'Bearer ' + this.confluencePersonalAccessToken, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } else { + configurationDocument = await got(configurationUrl, { + username: this.confluenceUser, + password: this.confluencePassword, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } } catch (e) { this._log.error(`Can't fetch configuration document: (${e.name}) ${e.message}`) throw e diff --git a/lib/api/Confluence.ts b/lib/api/Confluence.ts index 4670db8..72933ff 100644 --- a/lib/api/Confluence.ts +++ b/lib/api/Confluence.ts @@ -17,12 +17,14 @@ export class Confluence { public confluenceUrl: string public confluenceUser: string public confluencePassword: string + public confluencePersonalAccessToken: string private _log: Logger - constructor(confluenceUrl: string, confluenceUser: string, confluencePassword: string) { + constructor(confluenceUrl: string, confluenceUser: string, confluencePassword: string, confluencePersonalAccessToken: string) { this.confluenceUrl = confluenceUrl this.confluenceUser = confluenceUser this.confluencePassword = confluencePassword + this.confluencePersonalAccessToken = confluencePersonalAccessToken this._log = log.getLogger('Confluence') } @@ -47,11 +49,20 @@ export class Confluence { this._log.debug(`Searching for documents with ${cql}`) do { const configurationUrl = `${this.confluenceUrl}/rest/api/content/search?cql=${cql}&start=${start}&limit=${limit}` - results = await got(configurationUrl, { - username: this.confluenceUser, - password: this.confluencePassword, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }).json() + if (this.confluencePersonalAccessToken !== '') { + results = await got(configurationUrl, { + headers: { + Authorization: 'Bearer ' + this.confluencePersonalAccessToken, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } else { + results = await got(configurationUrl, { + username: this.confluenceUser, + password: this.confluencePassword, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } for (const result of results.results) { documentInfos.push(await this.getDocumentInfo(result.id)) } @@ -69,12 +80,21 @@ export class Confluence { public async getDocumentInfo(documentId: number): Promise { this._log.debug(`Getting document information of document ${documentId}`) const documentUrl = `${this.confluenceUrl}/rest/api/content/${documentId}?expand=ancestors,version,metadata.labels,history` - const document = await got(documentUrl, { - username: this.confluenceUser, - password: this.confluencePassword, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }).json() - + let document + if (this.confluencePersonalAccessToken !== '') { + document = await got(documentUrl, { + headers: { + Authorization: 'Bearer ' + this.confluencePersonalAccessToken, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } else { + document = await got(documentUrl, { + username: this.confluenceUser, + password: this.confluencePassword, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).json() + } const author = document.version.by.username ?? null const creator = document.history['createdBy'].username ?? null @@ -142,31 +162,61 @@ export class Confluence { public async createConfigurationDocument(space: string, title: string, parentId: string): Promise { const template = await fs.promises.readFile(path.join(__dirname, '..', '..', 'resources', 'configurationDocument.html'), 'utf-8') - const response = await got - .post(`${this.confluenceUrl}/rest/api/content`, { - json: { - type: 'page', - title: title, - space: { - key: space, + let response: any + if (this.confluencePersonalAccessToken !== '') { + response = await got + .post(`${this.confluenceUrl}/rest/api/content`, { + json: { + type: 'page', + title: title, + space: { + key: space, + }, + ancestors: [ + { + id: parentId, + }, + ], + body: { + storage: { + value: template, + representation: 'storage', + }, + }, + }, + headers: { + Authorization: 'Bearer ' + this.confluencePersonalAccessToken, }, - ancestors: [ - { - id: parentId, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .json() + } else { + response = await got + .post(`${this.confluenceUrl}/rest/api/content`, { + json: { + type: 'page', + title: title, + space: { + key: space, }, - ], - body: { - storage: { - value: template, - representation: 'storage', + ancestors: [ + { + id: parentId, + }, + ], + body: { + storage: { + value: template, + representation: 'storage', + }, }, }, - }, - username: this.confluenceUser, - password: this.confluencePassword, - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .json() + username: this.confluenceUser, + password: this.confluencePassword, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .json() + } return response.id } } diff --git a/lib/commands/Check.ts b/lib/commands/Check.ts index 23b4c46..fb51136 100644 --- a/lib/commands/Check.ts +++ b/lib/commands/Check.ts @@ -38,17 +38,28 @@ export default class extends Command { public async execute(options: CheckOptions): Promise { const log = options.getLogger() + if (options.confluencePersonalAccessToken === '' && (options.confluenceUser === '' || options.confluencePassword === '')) { + log.error('user and/or password parameter not set or empty! When not using the token parameter both of these have to be set!') + return + } + log.info('Checking for outdated documents') const configuration = new Configuration( options.confluenceUrl, options.confluenceUser, options.confluencePassword, + options.confluencePersonalAccessToken, options.configurationDocumentId ) await configuration.load() - const confluence = new Confluence(options.confluenceUrl, options.confluenceUser, options.confluencePassword) + const confluence = new Confluence( + options.confluenceUrl, + options.confluenceUser, + options.confluencePassword, + options.confluencePersonalAccessToken + ) const notification = new Notification(configuration, options.smtpTransportUrl, confluence, null, options.dryRun) diff --git a/lib/commands/CreateConfigurationDocument.ts b/lib/commands/CreateConfigurationDocument.ts index 44b6e3a..e2616ea 100644 --- a/lib/commands/CreateConfigurationDocument.ts +++ b/lib/commands/CreateConfigurationDocument.ts @@ -36,9 +36,19 @@ export default class extends Command { public async execute(options: CheckOptions): Promise { const log = options.getLogger() + if (options.confluencePersonalAccessToken === '' && (options.confluenceUser === '' || options.confluencePassword === '')) { + log.error('user and/or password parameter not set or empty! When not using the token parameter both of these have to be set!') + return + } + log.info('Checking for outdated documents') - const confluence = new Confluence(options.confluenceUrl, options.confluenceUser, options.confluencePassword) + const confluence = new Confluence( + options.confluenceUrl, + options.confluenceUser, + options.confluencePassword, + options.confluencePersonalAccessToken + ) const pageId = await confluence.createConfigurationDocument(options.space, options.title, options.parentId) diff --git a/test/ConfigurationTest.ts b/test/ConfigurationTest.ts index 4fef776..6efaff5 100644 --- a/test/ConfigurationTest.ts +++ b/test/ConfigurationTest.ts @@ -11,7 +11,7 @@ describe('The Configuration API', (): void => { const mockServer = new MockServer('https://example.com') mockServer.addConfigurationDocumentEndpoint() - const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12345') + const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12345') await configuration.load() chai.expect(configuration.checks).to.have.lengthOf(2) chai.expect(configuration.checks[0].labels).to.contain('test1') @@ -31,7 +31,7 @@ describe('The Configuration API', (): void => { const mockServer = new MockServer('https://example.com') mockServer.addConfigurationDocumentEndpoint() - const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12346') + const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12346') await configuration.load() chai.expect(configuration.exceptions).to.have.lengthOf(0) @@ -40,7 +40,7 @@ describe('The Configuration API', (): void => { const mockServer = new MockServer('https://example.com') mockServer.addConfigurationDocumentEndpoint() - const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12347') + const configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12347') await configuration.load() chai.expect(configuration.maintainer).to.have.lengthOf(1) }) diff --git a/test/ConfluenceTest.ts b/test/ConfluenceTest.ts index ee9d810..6229224 100644 --- a/test/ConfluenceTest.ts +++ b/test/ConfluenceTest.ts @@ -11,7 +11,7 @@ describe('The Confluence API', (): void => { const mockServer = new MockServer('https://example.com') mockServer.addSearchEndpoint() mockServer.addDocumentEndpoint() - const confluence = new Confluence('https://example.com', 'nobody', 'nothing') + const confluence = new Confluence('https://example.com', 'nobody', 'nothing', '') const results = await confluence.findDocumentsOlderThan('', 1, 1) chai.expect(results).to.have.lengthOf(2) chai.expect(results[0].url).to.eq('https://example.com/display/SAMPLE/Test') @@ -35,7 +35,40 @@ describe('The Confluence API', (): void => { it('should add a configuration document', async (): Promise => { const mockServer = new MockServer('https://example.com') mockServer.addCreateEndpoint() - const confluence = new Confluence('https://example.com', 'nobody', 'nothing') + const confluence = new Confluence('https://example.com', 'nobody', 'nothing', '') + const result = await confluence.createConfigurationDocument('example', 'test', '0123') + chai.expect(result).to.eq('12345') + }) + + it('should search for old documents using an access token', async (): Promise => { + const mockServer = new MockServer('https://example.com') + mockServer.addSearchEndpointToken() + mockServer.addDocumentEndpointToken() + const confluence = new Confluence('https://example.com', 'nobody', '', 'nothing') + const results = await confluence.findDocumentsOlderThan('', 1, 1) + chai.expect(results).to.have.lengthOf(2) + chai.expect(results[0].url).to.eq('https://example.com/display/SAMPLE/Test') + chai.expect(results[0].shortUrl).to.eq('/display/SAMPLE/Test') + chai.expect(results[0].author).to.eq('author') + chai.expect(results[0].id).to.eq(123) + chai.expect(results[0].lastVersionDate).to.eq('2019-12-31T22:00:00.000Z') + chai.expect(results[0].lastVersionMessage).to.eq('Some change') + chai.expect(results[0].title).to.eq('Test') + chai.expect(results[1].url).to.eq('https://example.com/display/SAMPLE/Test2') + chai.expect(results[1].shortUrl).to.eq('/display/SAMPLE/Test2') + chai.expect(results[1].author).to.eq('author2') + chai.expect(results[1].id).to.eq(234) + chai.expect(results[1].lastVersionDate).to.eq('2020-01-31T22:00:00.000Z') + chai.expect(results[1].lastVersionMessage).to.eq('') + chai.expect(results[1].title).to.eq('Test2') + chai.expect(results[0].labels.length).to.eq(1) + chai.expect(results[0].labels[0]).to.eq('Test') + }) + + it('should add a configuration document using an access token', async (): Promise => { + const mockServer = new MockServer('https://example.com') + mockServer.addCreateEndpointToken() + const confluence = new Confluence('https://example.com', 'nobody', '', 'nothing') const result = await confluence.createConfigurationDocument('example', 'test', '0123') chai.expect(result).to.eq('12345') }) diff --git a/test/MockServer.ts b/test/MockServer.ts index 7ae816c..65b3dad 100644 --- a/test/MockServer.ts +++ b/test/MockServer.ts @@ -665,6 +665,34 @@ export class MockServer { }) } + public addSearchEndpointToken(): void { + this._scope + .get(new RegExp('/rest/api/content/search\\?cql=.+&start=0')) + .matchHeader('authorization', 'Bearer nothing') + .reply(200, { + results: [ + { + id: 123, + }, + ], + start: 0, + size: 1, + totalSize: 2, + }) + .get(new RegExp('/rest/api/content/search\\?cql=.+&start=1')) + .matchHeader('authorization', 'Bearer nothing') + .reply(200, { + results: [ + { + id: 234, + }, + ], + start: 1, + size: 1, + totalSize: 2, + }) + } + public addDocumentEndpoint(): void { this._scope .get('/rest/api/content/123?expand=ancestors,version,metadata.labels,history') @@ -740,6 +768,75 @@ export class MockServer { }) } + public addDocumentEndpointToken(): void { + this._scope + .get('/rest/api/content/123?expand=ancestors,version,metadata.labels,history') + .matchHeader('authorization', 'Bearer nothing') + .reply(200, { + _links: { + base: 'https://example.com', + webui: '/display/SAMPLE/Test', + }, + ancestors: [ + { + title: 'main', + }, + ], + version: { + by: { + username: 'author', + }, + when: '2020-01-01T00:00:00.000+02:00', + message: 'Some change', + }, + history: { + createdBy: { + username: 'creator', + }, + }, + title: 'Test', + metadata: { + labels: { + results: [ + { + prefix: 'global', + name: 'Test', + id: '90734603', + }, + ], + }, + }, + }) + .get('/rest/api/content/234?expand=ancestors,version,metadata.labels,history') + .matchHeader('authorization', 'Bearer nothing') + .reply(200, { + _links: { + base: 'https://example.com', + webui: '/display/SAMPLE/Test2', + }, + ancestors: [ + { + title: 'main', + }, + { + title: 'Test', + }, + ], + history: { + createdBy: { + username: 'creator', + }, + }, + version: { + by: { + username: 'author2', + }, + when: '2020-02-01T00:00:00.000+02:00', + }, + title: 'Test2', + }) + } + public addCreateEndpoint(): void { this._scope .post('/rest/api/content') @@ -754,4 +851,16 @@ export class MockServer { return requestObject }) } + + public addCreateEndpointToken(): void { + this._scope + .post('/rest/api/content') + .matchHeader('authorization', 'Bearer nothing') + .reply(200, (uri, requestBody) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestObject = requestBody as Record + requestObject.id = '12345' + return requestObject + }) + } } diff --git a/test/NotificationTest.ts b/test/NotificationTest.ts index 12a987c..20f5e52 100644 --- a/test/NotificationTest.ts +++ b/test/NotificationTest.ts @@ -22,9 +22,9 @@ describe('The Notification API', (): void => { mockServer.addConfigurationDocumentEndpoint() mockServer.addSearchEndpoint() mockServer.addDocumentEndpoint() - configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12345') + configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12345') await configuration.load() - confluence = new Confluence('https://example.com', 'nobody', 'nothing') + confluence = new Confluence('https://example.com', 'nobody', 'nothing', '') transportStub = sinon.createStubInstance(Mail.prototype.constructor, { sendMail: sinon.stub().resolves(), }) as Mail @@ -68,7 +68,7 @@ describe('The Notification API', (): void => { ).to.be.true }) it('should use a maintainer when configured', async (): Promise => { - configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12347') + configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12347') await configuration.load() const notification = new Notification(configuration, '', confluence, transportStub) const documentInfo = new DocumentInfo( @@ -291,7 +291,7 @@ describe('The Notification API', (): void => { }) it('should use creator author if specified', async (): Promise => { - configuration = new Configuration('https://example.com', 'nobody', 'nothing', '12348') + configuration = new Configuration('https://example.com', 'nobody', 'nothing', '', '12348') await configuration.load() const notification = new Notification(configuration, '', confluence, transportStub, false) const documentInfo = new DocumentInfo(