diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d47438f5..4c9c2052e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: - node-version: 15.12.0 + node-version: 18.20.0 - - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: npm-${{ hashFiles('package-lock.json') }} diff --git a/.nvmrc b/.nvmrc index fac0b0a83..f4e1dd5b0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.20.0 +18.20.0 diff --git a/action.yml b/action.yml index e666faf1b..edf970e14 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,21 @@ inputs: github-actor: description: github actor to use to pull the docker image github.actor is probably fine required: true + slack-webhook-url: + description: 'Slack webhook URL for notifications' + required: false + slack-notify-on-error: + description: 'Send Slack notification when errors occur (default: true)' + required: false + default: 'true' + slack-notify-on-change: + description: 'Send Slack notification when membership changes are made' + required: false + default: 'false' + slack-notify-always: + description: 'Always send Slack notification regardless of changes' + required: false + default: 'false' runs: using: "composite" steps: @@ -63,6 +78,10 @@ runs: -e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \ -e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \ -e IGNORED_USERS="$IGNORED_USERS" \ + -e SLACK_WEBHOOK_URL="$SLACK_WEBHOOK_URL" \ + -e SLACK_NOTIFY_ON_ERROR="$SLACK_NOTIFY_ON_ERROR" \ + -e SLACK_NOTIFY_ON_CHANGE="$SLACK_NOTIFY_ON_CHANGE" \ + -e SLACK_NOTIFY_ALWAYS="$SLACK_NOTIFY_ALWAYS" \ docker.pkg.github.com/appvia/githubusermanager/githubusermanager:v1.0.5 shell: bash env: @@ -76,4 +95,8 @@ runs: GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }} GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }} IGNORED_USERS: ${{ inputs.ignored-users }} + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} + SLACK_NOTIFY_ON_ERROR: ${{ inputs.slack-notify-on-error }} + SLACK_NOTIFY_ON_CHANGE: ${{ inputs.slack-notify-on-change }} + SLACK_NOTIFY_ALWAYS: ${{ inputs.slack-notify-always }} diff --git a/index.ts b/index.ts index ac6237889..7dec25329 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,7 @@ import { getGithubUsersFromGoogle } from './src/google' -import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg } from './src/github' +import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg, OperationError } from './src/github' import { config } from './src/config' +import { notifySlack } from './src/slack' export async function run(): Promise { const googleUsers = await getGithubUsersFromGoogle() @@ -12,17 +13,49 @@ export async function run(): Promise { const usersNotInGithub = new Set(Array.from(googleUsers).filter((x) => !gitHubUsers.has(x))) const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x))) + let unfixedMismatch = false + const allErrors: OperationError[] = [] + const addedUsers: string[] = [] + const removedUsers: string[] = [] + if (usersNotInGithub.size > 0) { console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`) - if (config.addUsers) await addUsersToGitHubOrg(usersNotInGithub) + if (config.addUsers) { + const result = await addUsersToGitHubOrg(usersNotInGithub) + addedUsers.push(...result.success) + if (result.errors.length > 0) { + allErrors.push(...result.errors) + } + } else { + unfixedMismatch = true + } } if (usersNotInGoogle.size > 0) { console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`) - if (config.removeUsers) await removeUsersFromGitHubOrg(usersNotInGoogle) + if (config.removeUsers) { + const result = await removeUsersFromGitHubOrg(usersNotInGoogle) + removedUsers.push(...result.success) + if (result.errors.length > 0) { + allErrors.push(...result.errors) + } + } else { + unfixedMismatch = true + } + } + + if (allErrors.length > 0) { + console.error(`\n--- ERRORS SUMMARY ---`) + for (const err of allErrors) { + console.error(`[${err.operation.toUpperCase()}] ${err.user}: ${err.message}`) + } + console.error(`Total errors: ${allErrors.length}`) } - const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0 + await notifySlack(addedUsers, removedUsers, allErrors, config.githubOrg) + + const hasErrors = allErrors.length > 0 + const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0 process.exit(exitCode) } diff --git a/src/config.ts b/src/config.ts index 8dda2f379..ae74fd008 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,18 @@ export const config = { get googleEmailAddress(): string { return process.env.GOOGLE_EMAIL_ADDRESS ?? '' }, + get slackWebhookUrl(): string | undefined { + return process.env.SLACK_WEBHOOK_URL + }, + get slackNotifyOnError(): boolean { + return process.env.SLACK_NOTIFY_ON_ERROR?.toLowerCase() !== 'false' + }, + get slackNotifyOnChange(): boolean { + return process.env.SLACK_NOTIFY_ON_CHANGE?.toLowerCase() === 'true' + }, + get slackNotifyAlways(): boolean { + return process.env.SLACK_NOTIFY_ALWAYS?.toLowerCase() === 'true' + }, } export interface googleCredentials { diff --git a/src/github.ts b/src/github.ts index ee9afdfba..be1955ade 100644 --- a/src/github.ts +++ b/src/github.ts @@ -2,7 +2,18 @@ import { createAppAuth } from '@octokit/auth-app' import { Octokit } from '@octokit/rest' import * as mod from './github' import { config } from './config' -import { GetResponseTypeFromEndpointMethod } from '@octokit/types' + +export interface OperationError { + user: string + operation: 'add' | 'remove' + message: string + status?: number +} + +export interface OperationResult { + success: string[] + errors: OperationError[] +} export function getAuthenticatedOctokit(): Octokit { return new Octokit({ @@ -57,47 +68,85 @@ export async function getUserIdFromUsername(username: string): Promise { return user.data.id } -export async function addUsersToGitHubOrg(users: Set): Promise { +export async function addUsersToGitHubOrg(users: Set): Promise { + const result: OperationResult = { success: [], errors: [] } for (const user of users) { - await mod.addUserToGitHubOrg(user) + const outcome = await mod.addUserToGitHubOrg(user) + if (outcome === true) { + result.success.push(user) + } else if (outcome !== false && 'error' in outcome) { + result.errors.push(outcome.error) + } } + return result } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function addUserToGitHubOrg( - user: string, -): Promise | boolean> { +export async function addUserToGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> { const octokit = mod.getAuthenticatedOctokit() if (config.ignoredUsers.includes(user.toLowerCase())) { console.log(`Ignoring add for ${user}`) return false } - const userId = await mod.getUserIdFromUsername(user) - console.log(`Inviting ${user} (${userId} to ${config.githubOrg})`) - return await octokit.orgs.createInvitation({ - org: config.githubOrg, - invitee_id: userId, - }) + try { + const userId = await mod.getUserIdFromUsername(user) + console.log(`Inviting ${user} (${userId}) to ${config.githubOrg}`) + await octokit.orgs.createInvitation({ + org: config.githubOrg, + invitee_id: userId, + }) + return true + } catch (error) { + const status = error?.status || error?.response?.status + let message = error?.message || String(error) + if (status === 422) { + message = `Validation failed: ${message} (user may already be invited, or org is at max capacity)` + } else if (status === 404) { + message = `User not found: ${user}` + } else if (status === 403) { + message = `Permission denied or rate limited` + } + console.error(`Error adding ${user}: ${message}`) + return { error: { user, operation: 'add', message, status } } + } } -export async function removeUsersFromGitHubOrg(users: Set): Promise { +export async function removeUsersFromGitHubOrg(users: Set): Promise { + const result: OperationResult = { success: [], errors: [] } for (const user of users) { - await mod.removeUserFromGitHubOrg(user) + const outcome = await mod.removeUserFromGitHubOrg(user) + if (outcome === true) { + result.success.push(user) + } else if (outcome !== false && 'error' in outcome) { + result.errors.push(outcome.error) + } } + return result } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function removeUserFromGitHubOrg( - user: string, -): Promise | boolean> { +export async function removeUserFromGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> { const octokit = mod.getAuthenticatedOctokit() if (config.ignoredUsers.includes(user.toLowerCase())) { console.log(`Ignoring remove for ${user}`) return false } - console.log(`Removing user/invitation ${user} from ${config.githubOrg}`) - return octokit.orgs.removeMembershipForUser({ - org: config.githubOrg, - username: user, - }) + try { + console.log(`Removing user/invitation ${user} from ${config.githubOrg}`) + await octokit.orgs.removeMembershipForUser({ + org: config.githubOrg, + username: user, + }) + return true + } catch (error) { + const status = error?.status || error?.response?.status + let message = error?.message || String(error) + if (status === 404) { + message = `User not found or not a member: ${user}` + } else if (status === 403) { + message = `Permission denied or rate limited` + } + console.error(`Error removing ${user}: ${message}`) + return { error: { user, operation: 'remove', message, status } } + } } diff --git a/src/google.ts b/src/google.ts index e7b5c91c7..cb36543b7 100644 --- a/src/google.ts +++ b/src/google.ts @@ -31,7 +31,8 @@ export async function getGithubUsersFromGoogle(): Promise> { customer: 'my_customer', maxResults: 250, projection: 'custom', - fields: 'users(customSchemas/Accounts/github(value))', + query: 'isSuspended=false', + fields: 'users(customSchemas/Accounts/github(value),suspended,archived)', customFieldMask: 'Accounts', }) @@ -43,6 +44,7 @@ export async function getGithubUsersFromGoogle(): Promise> { export function formatUserList(users): Set { return new Set( users + .filter((user) => !user.suspended && !user.archived) .map((user) => user.customSchemas?.Accounts?.github?.map((account) => account.value?.toLowerCase())) .flat() .filter(Boolean), diff --git a/src/slack.ts b/src/slack.ts new file mode 100644 index 000000000..e1356bb84 --- /dev/null +++ b/src/slack.ts @@ -0,0 +1,141 @@ +import * as https from 'https' +import { URL } from 'url' +import { config } from './config' +import { OperationError } from './github' + +export interface SlackMessage { + text: string + blocks?: SlackBlock[] +} + +export interface SlackBlock { + type: string + text?: { type: string; text: string } + elements?: { type: string; text: string }[] +} + +export async function sendSlackNotification(message: SlackMessage): Promise { + const webhookUrl = config.slackWebhookUrl + if (!webhookUrl) { + return false + } + + return new Promise((resolve) => { + try { + const url = new URL(webhookUrl) + const postData = JSON.stringify(message) + + const options = { + hostname: url.hostname, + port: 443, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + } + + const req = https.request(options, (res) => { + if (res.statusCode === 200) { + resolve(true) + } else { + console.error(`Slack notification failed: ${res.statusCode}`) + resolve(false) + } + }) + + req.on('error', (error) => { + console.error(`Slack notification error: ${error}`) + resolve(false) + }) + + req.write(postData) + req.end() + } catch (error) { + console.error(`Slack notification error: ${error}`) + resolve(false) + } + }) +} + +export function formatMembershipUpdate( + added: string[], + removed: string[], + errors: OperationError[], + org: string, +): SlackMessage { + const hasChanges = added.length > 0 || removed.length > 0 + const hasErrors = errors.length > 0 + + let emoji = ':white_check_mark:' + if (hasErrors && !hasChanges) { + emoji = ':x:' + } else if (hasErrors) { + emoji = ':warning:' + } + + const blocks: SlackBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: `${emoji} GitHub Org Sync: ${org}` }, + }, + ] + + if (added.length > 0) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Users Added:* ${added.join(', ')}` }, + }) + } + + if (removed.length > 0) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Users Removed:* ${removed.join(', ')}` }, + }) + } + + if (errors.length > 0) { + const errorText = errors.map((e) => `• [${e.operation}] ${e.user}: ${e.message}`).join('\n') + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Errors:*\n${errorText}` }, + }) + } + + if (!hasChanges && !hasErrors) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: 'No changes required - all users in sync.' }, + }) + } + + return { + text: `GitHub Org Sync: ${added.length} added, ${removed.length} removed, ${errors.length} errors`, + blocks, + } +} + +export async function notifySlack( + added: string[], + removed: string[], + errors: OperationError[], + org: string, +): Promise { + const hasChanges = added.length > 0 || removed.length > 0 + const hasErrors = errors.length > 0 + + const shouldNotify = + (hasErrors && config.slackNotifyOnError) || (hasChanges && config.slackNotifyOnChange) || config.slackNotifyAlways + + if (!shouldNotify) { + return + } + + const message = formatMembershipUpdate(added, removed, errors, org) + const sent = await sendSlackNotification(message) + if (sent) { + console.log('Slack notification sent') + } +} diff --git a/tests/__snapshots__/github.spec.ts.snap b/tests/__snapshots__/github.spec.ts.snap index 497741c1e..5d04de588 100644 --- a/tests/__snapshots__/github.spec.ts.snap +++ b/tests/__snapshots__/github.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`github integration addUserToGitHubOrg 1`] = ` +exports[`github integration addUserToGitHubOrg success 1`] = ` [MockFunction] { "calls": Array [ Array [ @@ -19,29 +19,6 @@ exports[`github integration addUserToGitHubOrg 1`] = ` } `; -exports[`github integration addUsersToGitHubOrg 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "foo", - ], - Array [ - "bar", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - exports[`github integration getAuthenticatedOctokit 1`] = ` [MockFunction] { "calls": Array [ @@ -79,7 +56,7 @@ exports[`github integration getUserIdFromUsername found 1`] = `123`; exports[`github integration getUserIdFromUsername notfound 1`] = `"Unable to find user id for foo"`; -exports[`github integration removeUserFromGitHubOrg 1`] = ` +exports[`github integration removeUserFromGitHubOrg success 1`] = ` [MockFunction] { "calls": Array [ Array [ @@ -97,26 +74,3 @@ exports[`github integration removeUserFromGitHubOrg 1`] = ` ], } `; - -exports[`github integration removeUsersFromGitHubOrg 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "foo", - ], - Array [ - "bar", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`; diff --git a/tests/__snapshots__/index.spec.ts.snap b/tests/__snapshots__/index.spec.ts.snap index 457d9a6ea..0d17066ee 100644 --- a/tests/__snapshots__/index.spec.ts.snap +++ b/tests/__snapshots__/index.spec.ts.snap @@ -35,7 +35,7 @@ exports[`missmatch should add users if set to 1`] = ` "results": Array [ Object { "type": "return", - "value": undefined, + "value": Promise {}, }, ], } @@ -128,7 +128,7 @@ exports[`missmatch should remove users if set to 1`] = ` "results": Array [ Object { "type": "return", - "value": undefined, + "value": Promise {}, }, ], } diff --git a/tests/github.spec.ts b/tests/github.spec.ts index 51c08ae6c..8575d2dc2 100644 --- a/tests/github.spec.ts +++ b/tests/github.spec.ts @@ -9,6 +9,7 @@ describe('github integration', () => { jest.spyOn(config, 'githubAppID', 'get').mockReturnValue(123) jest.spyOn(config, 'githubInstallationID', 'get').mockReturnValue(123) jest.spyOn(global.console, 'log').mockImplementation() + jest.spyOn(global.console, 'error').mockImplementation() }) it('getAuthenticatedOctokit', () => { @@ -49,18 +50,40 @@ describe('github integration', () => { return expect(mod.getUserIdFromUsername('foo')).rejects.toMatchSnapshot() }) - it('addUsersToGitHubOrg', async () => { + it('addUsersToGitHubOrg collects successes', async () => { const users = new Set(['foo', 'bar']) - jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue(false) - await mod.addUsersToGitHubOrg(users) - return expect(mod.addUserToGitHubOrg).toMatchSnapshot() + jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue(true) + const result = await mod.addUsersToGitHubOrg(users) + expect(result.success).toEqual(['foo', 'bar']) + expect(result.errors).toEqual([]) }) - it('removeUsersFromGitHubOrg', async () => { + it('addUsersToGitHubOrg collects errors', async () => { const users = new Set(['foo', 'bar']) - jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue(false) - await mod.removeUsersFromGitHubOrg(users) - return expect(mod.removeUserFromGitHubOrg).toMatchSnapshot() + jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue({ + error: { user: 'foo', operation: 'add', message: 'test error', status: 422 }, + }) + const result = await mod.addUsersToGitHubOrg(users) + expect(result.success).toEqual([]) + expect(result.errors.length).toBe(2) + }) + + it('removeUsersFromGitHubOrg collects successes', async () => { + const users = new Set(['foo', 'bar']) + jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue(true) + const result = await mod.removeUsersFromGitHubOrg(users) + expect(result.success).toEqual(['foo', 'bar']) + expect(result.errors).toEqual([]) + }) + + it('removeUsersFromGitHubOrg collects errors', async () => { + const users = new Set(['foo', 'bar']) + jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue({ + error: { user: 'foo', operation: 'remove', message: 'test error', status: 404 }, + }) + const result = await mod.removeUsersFromGitHubOrg(users) + expect(result.success).toEqual([]) + expect(result.errors.length).toBe(2) }) it('removeUserFromGitHubOrg skip ignore', () => { @@ -73,7 +96,7 @@ describe('github integration', () => { expect(mod.addUserToGitHubOrg('foo')).resolves.toBe(false) }) - it('addUserToGitHubOrg', async () => { + it('addUserToGitHubOrg success', async () => { const fakeOctokit = { orgs: { createInvitation: jest.fn().mockResolvedValue(true), @@ -83,22 +106,76 @@ describe('github integration', () => { jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.addUserToGitHubOrg('foo') - return expect(fakeOctokit.orgs.createInvitation).toMatchSnapshot() + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toBe(true) + expect(fakeOctokit.orgs.createInvitation).toMatchSnapshot() }) - it('removeUserFromGitHubOrg', async () => { + it('addUserToGitHubOrg handles 422 error (org full)', async () => { const fakeOctokit = { orgs: { - removeMembershipForUser: jest.fn().mockResolvedValue(true), + createInvitation: jest.fn().mockRejectedValue({ status: 422, message: 'Validation Failed' }), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(422) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('org is at max capacity') + }) + + it('addUserToGitHubOrg handles 403 error (rate limit)', async () => { + const fakeOctokit = { + orgs: { + createInvitation: jest.fn().mockRejectedValue({ status: 403, message: 'Forbidden' }), }, } jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.removeUserFromGitHubOrg('foo') - return expect(fakeOctokit.orgs.removeMembershipForUser).toMatchSnapshot() + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(403) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('rate limited') + }) + + it('removeUserFromGitHubOrg success', async () => { + const fakeOctokit = { + orgs: { + removeMembershipForUser: jest.fn().mockResolvedValue(true), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.removeUserFromGitHubOrg('foo') + expect(result).toBe(true) + expect(fakeOctokit.orgs.removeMembershipForUser).toMatchSnapshot() + }) + + it('removeUserFromGitHubOrg handles 404 error', async () => { + const fakeOctokit = { + orgs: { + removeMembershipForUser: jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.removeUserFromGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(404) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('not a member') }) it('formatUserList', () => { diff --git a/tests/google.spec.ts b/tests/google.spec.ts index 6d04c3c57..dc2fe0aa9 100644 --- a/tests/google.spec.ts +++ b/tests/google.spec.ts @@ -3,19 +3,30 @@ import { google } from 'googleapis' import * as mod from '../src/google' const fakeUsersResponse = [ - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, + { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } }, suspended: false, archived: false }, { customSchemas: { Accounts: { github: [{ value: 'Foo' }, , { value: 'tar' }] }, }, + suspended: false, + archived: false, }, { customSchemas: { Accounts: { github: [{ value: 'foo' }, { value: 'bar' }] }, }, + suspended: false, + archived: false, }, ] +const fakeUsersResponseWithSuspended = [ + { customSchemas: { Accounts: { github: [{ value: 'activeuser' }] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'suspendeduser' }] } }, suspended: true, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'archiveduser' }] } }, suspended: false, archived: true }, + { customSchemas: { Accounts: { github: [{ value: 'botharchivedandsuspended' }] } }, suspended: true, archived: true }, +] + describe('google integration', () => { beforeEach(() => { process.env.GOOGLE_EMAIL_ADDRESS = 'hello@example.com' @@ -49,12 +60,26 @@ describe('google integration', () => { it('formatUserList bad', () => expect( mod.formatUserList([ - {}, - { customSchemas: {} }, - { customSchemas: { Accounts: {} } }, - { customSchemas: { Accounts: { github: [] } } }, - { customSchemas: { Accounts: { github: [{}] } } }, - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, + { suspended: false, archived: false }, + { customSchemas: {}, suspended: false, archived: false }, + { customSchemas: { Accounts: {} }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{}] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } }, suspended: false, archived: false }, ]), ).toMatchSnapshot()) + + it('formatUserList filters out suspended users', () => { + const result = mod.formatUserList(fakeUsersResponseWithSuspended) + expect(result).toEqual(new Set(['activeuser'])) + }) + + it('formatUserList filters out archived users', () => { + const users = [ + { customSchemas: { Accounts: { github: [{ value: 'active' }] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'archived' }] } }, suspended: false, archived: true }, + ] + const result = mod.formatUserList(users) + expect(result).toEqual(new Set(['active'])) + }) }) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 0e1aa229a..d8851ad41 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -1,17 +1,24 @@ jest.mock('../src/google') jest.mock('../src/github') +jest.mock('../src/slack') import * as google from '../src/google' import * as github from '../src/github' import * as mod from '../index' let processExitSpy let consoleSpy +let consoleErrorSpy beforeEach(() => { processExitSpy = jest.spyOn(global.process, 'exit').mockImplementation(() => { return undefined as never }) consoleSpy = jest.spyOn(global.console, 'log').mockImplementation() + consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation() + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ success: [], errors: [] }) + // @ts-expect-error mockResolved unexpected + github.removeUsersFromGitHubOrg.mockResolvedValue({ success: [], errors: [] }) }) describe('missmatch', () => { @@ -30,8 +37,24 @@ describe('missmatch', () => { await mod.run() return expect(processExitSpy).toBeCalledWith(0) }) - it('should exit with 122 if defined when there is a missmatch', async () => { + it('should exit with 122 if defined when there is an unfixed missmatch', async () => { process.env.EXIT_CODE_ON_MISMATCH = '122' + delete process.env.ADD_USERS + delete process.env.REMOVE_USERS + await mod.run() + return expect(processExitSpy).toBeCalledWith(122) + }) + it('should exit with 0 when mismatch is fixed by adding users', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '122' + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + await mod.run() + return expect(processExitSpy).toBeCalledWith(0) + }) + it('should exit with 122 when only add is enabled but remove mismatch exists', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '122' + process.env.ADD_USERS = 'true' + delete process.env.REMOVE_USERS await mod.run() return expect(processExitSpy).toBeCalledWith(122) }) @@ -94,3 +117,38 @@ describe('match', () => { return expect(github.removeUsersFromGitHubOrg).not.toBeCalled() }) }) + +describe('error handling', () => { + beforeEach(() => { + // @ts-expect-error mockResolved unexpected + google.getGithubUsersFromGoogle.mockResolvedValue(new Set(['a', 'd'])) + // @ts-expect-error mockResolved unexpected + github.getGithubUsersFromGithub.mockResolvedValue(new Set(['b', 'c', 'a'])) + }) + + it('should exit with non-zero when errors occur', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '1' + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ + success: [], + errors: [{ user: 'd', operation: 'add', message: 'org full', status: 422 }], + }) + await mod.run() + expect(processExitSpy).toBeCalledWith(1) + }) + + it('should output error summary when errors occur', async () => { + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ + success: [], + errors: [{ user: 'd', operation: 'add', message: 'org full', status: 422 }], + }) + await mod.run() + expect(consoleErrorSpy).toHaveBeenCalledWith('\n--- ERRORS SUMMARY ---') + expect(consoleErrorSpy).toHaveBeenCalledWith('[ADD] d: org full') + }) +}) diff --git a/tests/slack.spec.ts b/tests/slack.spec.ts new file mode 100644 index 000000000..e2d15f7ca --- /dev/null +++ b/tests/slack.spec.ts @@ -0,0 +1,157 @@ +jest.mock('https') +import * as https from 'https' +import { config } from '../src/config' +import * as mod from '../src/slack' +import { EventEmitter } from 'events' + +describe('slack integration', () => { + let consoleErrorSpy: jest.SpyInstance + + beforeEach(() => { + jest.spyOn(global.console, 'log').mockImplementation() + consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('sendSlackNotification', () => { + it('returns false when no webhook URL configured', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue(undefined) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + }) + + it('sends notification when webhook URL is configured', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockRes = new EventEmitter() as any + mockRes.statusCode = 200 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(true) + }) + + it('returns false when request fails with non-200', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockRes = new EventEmitter() as any + mockRes.statusCode = 500 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('returns false when request errors', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + ;(https.request as jest.Mock).mockImplementation(() => { + setTimeout(() => mockReq.emit('error', new Error('Network error')), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + }) + + describe('formatMembershipUpdate', () => { + it('formats message with added users', () => { + const message = mod.formatMembershipUpdate(['user1', 'user2'], [], [], 'myorg') + expect(message.text).toContain('2 added') + expect(message.blocks).toBeDefined() + expect(JSON.stringify(message.blocks)).toContain('user1, user2') + }) + + it('formats message with removed users', () => { + const message = mod.formatMembershipUpdate([], ['user1'], [], 'myorg') + expect(message.text).toContain('1 removed') + expect(JSON.stringify(message.blocks)).toContain('user1') + }) + + it('formats message with errors', () => { + const errors = [{ user: 'baduser', operation: 'add' as const, message: 'failed', status: 422 }] + const message = mod.formatMembershipUpdate([], [], errors, 'myorg') + expect(message.text).toContain('1 errors') + expect(JSON.stringify(message.blocks)).toContain('baduser') + }) + + it('formats message when no changes', () => { + const message = mod.formatMembershipUpdate([], [], [], 'myorg') + expect(JSON.stringify(message.blocks)).toContain('No changes required') + }) + }) + + describe('notifySlack', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockReq: any + + beforeEach(() => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockRes = new EventEmitter() as any + mockRes.statusCode = 200 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + }) + + it('does not send when no changes and slackNotifyAlways is false', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + await mod.notifySlack([], [], [], 'myorg') + expect(https.request).not.toHaveBeenCalled() + }) + + it('sends when slackNotifyAlways is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(true) + await mod.notifySlack([], [], [], 'myorg') + expect(https.request).toHaveBeenCalled() + }) + + it('sends when errors occur and slackNotifyOnError is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + const errors = [{ user: 'baduser', operation: 'add' as const, message: 'failed', status: 422 }] + await mod.notifySlack([], [], errors, 'myorg') + expect(https.request).toHaveBeenCalled() + }) + + it('sends when changes occur and slackNotifyOnChange is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + await mod.notifySlack(['newuser'], [], [], 'myorg') + expect(https.request).toHaveBeenCalled() + }) + }) +})