Skip to content
24 changes: 24 additions & 0 deletions __fixtures__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ const sampleGithubOrg = {
secret_scanning_push_protection_custom_link_enabled: false
}

const sampleGithubOrgMembers = [
{
login: 'octocat',
id: 1,
node_id: 'MDQ6VXNlcjE=',
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
gravatar_id: '',
url: 'https://api.github.com/users/octocat',
html_url: 'https://github.com/octocat',
followers_url: 'https://api.github.com/users/octocat/followers',
following_url: 'https://api.github.com/users/octocat/following{/other_user}',
gists_url: 'https://api.github.com/users/octocat/gists{/gist_id}',
starred_url: 'https://api.github.com/users/octocat/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/octocat/subscriptions',
organizations_url: 'https://api.github.com/users/octocat/orgs',
repos_url: 'https://api.github.com/users/octocat/repos',
events_url: 'https://api.github.com/users/octocat/events{/privacy}',
received_events_url: 'https://api.github.com/users/octocat/received_events',
type: 'User',
site_admin: false
}
]

// https://docs.github.com/en/rest/reference/repos#list-organization-repositories
const sampleGithubListOrgRepos = [
{
Expand Down Expand Up @@ -922,6 +945,7 @@ const sampleBulkImportFileContent = [{

module.exports = {
sampleGithubOrg,
sampleGithubOrgMembers,
sampleGithubListOrgRepos,
sampleGithubRepository,
sampleOSSFScorecardResult,
Expand Down
5 changes: 5 additions & 0 deletions __tests__/cli/__snapshots__/workflows.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ exports[`list - Non-Interactive Mode Should provide a list of available workflow
"name": "upsert-github-repositories",
"workflow": [Function],
},
{
"description": "Check the organizations stored and update/create the information related to the organization members with the GitHub API.",
"name": "upsert-github-organization-members",
"workflow": [Function],
},
{
"description": "Run all the compliance checks for the stored data.",
"name": "run-all-checks",
Expand Down
84 changes: 83 additions & 1 deletion __tests__/cli/workflows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { getConfig } = require('../../src/config')
const { runWorkflowCommand, listWorkflowCommand } = require('../../src/cli')
const { resetDatabase, initializeStore } = require('../../__utils__')
const { github } = require('../../src/providers')
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository } = require('../../__fixtures__')
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleGithubOrgMembers } = require('../../__fixtures__')

const { dbSettings } = getConfig('test')

Expand All @@ -15,16 +15,24 @@ let getAllProjects,
addGithubOrg,
addProject,
getAllGithubRepos,
getAllGithubUsers,
getAllGithubOrgMembers,
upsertGithubMembers,
upsertGithubOrganizationMembers,
addGithubRepo

beforeAll(() => {
knex = knexInit(dbSettings);
({
getAllProjects,
getAllGithubUsers,
getAllGithubOrganizations: getAllGithubOrgs,
getAllGithubOrganizationMembers: getAllGithubOrgMembers,
addGithubOrganization: addGithubOrg,
addProject,
getAllGithubRepositories: getAllGithubRepos,
upsertGithubMembers,
upsertGithubOrganizationMembers,
addGithubRepo
} = initializeStore(knex))
})
Expand Down Expand Up @@ -150,6 +158,80 @@ describe('run upsert-github-repositories', () => {
test.todo('Should throw an error when the Github API is not available')
})

describe('run upsert-github-organization-members', () => {
test('Should throw an error when no Github orgs are stored in the database', async () => {
const projects = await getAllProjects()
expect(projects.length).toBe(0)
const githubOrgs = await getAllGithubOrgs()
expect(githubOrgs.length).toBe(0)
await expect(runWorkflowCommand(knex, { name: 'upsert-github-organization-members' }))
.rejects
.toThrow('No organizations found. Please add organizations/projects before running this workflow.')
})
test('Should add the organization members related to the organization', async () => {
// Prepare the database
const project = await addProject({ name: sampleGithubOrg.login })
await addGithubOrg({ login: sampleGithubOrg.login, html_url: sampleGithubOrg.html_url, project_id: project.id })
const projects = await getAllProjects()
expect(projects.length).toBe(1)
const githubOrgs = await getAllGithubOrgs()
expect(githubOrgs.length).toBe(1)
let githubUsers = await getAllGithubUsers()
let githubOrgMembers = await getAllGithubOrgMembers()
expect(githubOrgMembers.length).toBe(0)
expect(githubUsers.length).toBe(0)
// Mock the github methods used
jest.spyOn(github, 'fetchOrgMembersByLogin').mockResolvedValue(sampleGithubOrgMembers)
await runWorkflowCommand(knex, { name: 'upsert-github-organization-members' })
// Check the database changes
githubUsers = await getAllGithubUsers()
githubOrgMembers = await getAllGithubOrgMembers()
expect(githubUsers.length).toBe(1)
expect(githubUsers[0].login).toBe(sampleGithubOrgMembers[0].login)
expect(githubOrgMembers.length).toBe(1)
expect(githubOrgMembers[0].github_user_id).toBe(githubUsers[0].id)
expect(githubOrgMembers[0].github_organization_id).toBe(githubOrgs[0].id)
})
test('Should update the organization members related to the organization', async () => {
const project = await addProject({ name: sampleGithubOrg.login })
const org = await addGithubOrg({ login: sampleGithubOrg.login, html_url: sampleGithubOrg.html_url, project_id: project.id })
const githubUserData = simplifyObject(sampleGithubOrgMembers[0], {
include: [
'login', 'node_id', 'avatar_url', 'gravatar_id', 'url', 'html_url', 'followers_url', 'following_url', 'gists_url', 'starred_url', 'subscriptions_url', 'organizations_url', 'repos_url', 'events_url', 'received_events_url', 'type', 'site_admin', 'starred_at', 'user_view_type'
]
})
githubUserData.github_user_id = sampleGithubOrgMembers[0].id
githubUserData.login = 'express'
const [user] = await upsertGithubMembers(githubUserData)
await upsertGithubOrganizationMembers({
github_user_id: user.id,
github_organization_id: org.id
})
const projects = await getAllProjects()
expect(projects.length).toBe(1)
const githubOrgs = await getAllGithubOrgs()
expect(githubOrgs.length).toBe(1)
let githubUsers = await getAllGithubUsers()
expect(githubUsers.length).toBe(1)
expect(githubUsers[0].login).toBe('express')
let githubOrgMembers = await getAllGithubOrgMembers()
expect(githubOrgMembers.length).toBe(1)
expect(githubOrgMembers[0].github_user_id).toBe(githubUsers[0].id)
expect(githubOrgMembers[0].github_organization_id).toBe(githubOrgs[0].id)
jest.spyOn(github, 'fetchOrgMembersByLogin').mockResolvedValue(sampleGithubOrgMembers)
await runWorkflowCommand(knex, { name: 'upsert-github-organization-members' })
// Check the database changes
githubUsers = await getAllGithubUsers()
githubOrgMembers = await getAllGithubOrgMembers()
expect(githubUsers.length).toBe(1)
expect(githubUsers[0].login).toBe(sampleGithubOrgMembers[0].login)
expect(githubOrgMembers.length).toBe(1)
expect(githubOrgMembers[0].github_user_id).toBe(githubUsers[0].id)
expect(githubOrgMembers[0].github_organization_id).toBe(githubOrgs[0].id)
})
test.todo('Should throw an error when the Github API is not available')
})

describe('run run-all-checks', () => {
test.todo('Should run all the compliance checks for the stored data')
})
Expand Down
53 changes: 52 additions & 1 deletion __tests__/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const {
sampleGithubOrg,
sampleGithubRepository,
sampleOSSFScorecardResult,
sampleGithubListOrgRepos
sampleGithubListOrgRepos,
sampleGithubOrgMembers
} = require('../__fixtures__')
const nock = require('nock')

Expand All @@ -15,6 +16,13 @@ const largeSampleGithubListOrgRepos = Array.from({ length: 150 }, (_, i) => ({
full_name: `org/repo-${i + 1}`
}))

// Create a larger sample data set for pagination testing
const largeSampleGithubListOrgMembers = Array.from({ length: 150 }, (_, i) => ({
...sampleGithubOrgMembers[0],
id: i + 1,
node_id: `repo-${i + 1}`
}))

describe('GitHub Provider', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'github_pat_mock_token'
Expand Down Expand Up @@ -95,6 +103,49 @@ describe('GitHub Provider', () => {
})
})

describe('fetchOrgMembersByLogin', () => {
it.each([undefined, null, ''])('Should throw when no login are provided', async (login) => {
await expect(github.fetchOrgMembersByLogin(login)).rejects.toThrow('Organization name is required')
})

it('Should fetch organization members by login', async () => {
nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(200, sampleGithubOrgMembers)

await expect(github.fetchOrgMembersByLogin('github')).resolves.toEqual(sampleGithubOrgMembers)
})

it('Should handle pagination correctly', async () => {
const firstPageRepos = largeSampleGithubListOrgMembers.slice(0, 100)
const secondPageRepos = largeSampleGithubListOrgMembers.slice(100)

nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(200, firstPageRepos, {
link: '<https://api.github.com/orgs/github/members?per_page=100&page=2>; rel="next"'
})
.get('/orgs/github/members?per_page=100&page=2')
.reply(200, secondPageRepos, {
link: null
})

await expect(github.fetchOrgMembersByLogin('github')).resolves.toEqual([...firstPageRepos, ...secondPageRepos])
})

it('Should throw an error if the organization does not exist', async () => {
nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(404, {
message: 'Not Found',
documentation_url: 'https://docs.github.com/rest/orgs/members#list-organization-members',
status: '404'
})

await expect(github.fetchOrgMembersByLogin('github')).rejects.toThrow('Not Found - https://docs.github.com/rest/orgs/members#list-organization-members')
})
})

describe('fetchRepoByFullName', () => {
it.each([undefined, null, ''])('Should throw when no full name are provided', async (fullName) => {
await expect(github.fetchRepoByFullName(fullName)).rejects.toThrow('The full name is required')
Expand Down
27 changes: 25 additions & 2 deletions __tests__/schemas.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, sampleBulkImportFileContent } = require('../__fixtures__')
const { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, validateBulkImport } = require('../src/schemas')
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, sampleBulkImportFileContent, sampleGithubOrgMembers } = require('../__fixtures__')
const { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, validateBulkImport, validateGithubListOrgMembers } = require('../src/schemas')

describe('schemas', () => {
describe('validateGithubOrg', () => {
Expand Down Expand Up @@ -38,6 +38,29 @@ describe('schemas', () => {
expect(() => validateGithubListOrgRepos(invalidData)).toThrow()
})
})

describe('validateGithubListOrgMembers', () => {
test('Should not throw an error with valid data', () => {
expect(() => validateGithubListOrgMembers(sampleGithubOrgMembers)).not.toThrow()
})

test('Should not throw an error with additional data', () => {
const additionalData = [
...sampleGithubOrgMembers,
{ ...sampleGithubOrgMembers[0], additionalKey: 'value' }
]
expect(() => validateGithubListOrgMembers(additionalData)).not.toThrow()
})

test('Should throw an error with invalid data', () => {
const invalidData = [
...sampleGithubOrgMembers,
{ ...sampleGithubOrgMembers[0], id: '123' }
]
expect(() => validateGithubListOrgMembers(invalidData)).toThrow()
})
})

describe('validateGithubRepository', () => {
test('Should not throw an error with valid data', () => {
expect(() => validateGithubRepository(sampleGithubRepository)).not.toThrow()
Expand Down
2 changes: 2 additions & 0 deletions __utils__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const resetDatabase = async (knex) => {
await knex.raw('TRUNCATE TABLE compliance_checks_alerts RESTART IDENTITY CASCADE')
await knex.raw('TRUNCATE TABLE github_repositories RESTART IDENTITY CASCADE')
await knex.raw('TRUNCATE TABLE github_organizations RESTART IDENTITY CASCADE')
await knex.raw('TRUNCATE TABLE github_users RESTART IDENTITY CASCADE')
await knex.raw('TRUNCATE TABLE github_organization_members RESTART IDENTITY CASCADE')
await knex.raw('TRUNCATE TABLE projects RESTART IDENTITY CASCADE')
}

Expand Down
7 changes: 6 additions & 1 deletion src/cli/workflows.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const inquirer = require('inquirer').default
const debug = require('debug')('cli:workflows')
const { updateGithubOrgs, upsertGithubRepositories, runAllTheComplianceChecks, upsertOSSFScorecardAnalysis } = require('../workflows')
const { updateGithubOrgs, upsertGithubRepositories, upsertGithubOrganizationMembers, runAllTheComplianceChecks, upsertOSSFScorecardAnalysis } = require('../workflows')
const { generateReports } = require('../reports')
const { bulkImport } = require('../importers')
const { logger } = require('../utils')
Expand All @@ -14,6 +14,11 @@ const commandList = [{
description: 'Check the organizations stored and update/create the information related to the repositories with the GitHub API.',
workflow: upsertGithubRepositories
}, {
name: 'upsert-github-organization-members',
description: 'Check the organizations stored and update/create the information related to the organization members with the GitHub API.',
workflow: upsertGithubOrganizationMembers
},
{
name: 'run-all-checks',
description: 'Run all the compliance checks for the stored data.',
workflow: runAllTheComplianceChecks
Expand Down
52 changes: 52 additions & 0 deletions src/database/migrations/20250224195123_github_users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async (knex) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table is being created with the response from https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#list-organization-members, I think it makes the most sense. I don't think we need all the user information.

await knex.schema.createTable('github_users', (table) => {
table.increments('id').primary() // Primary key
table.string('name')
table.string('email')
table.string('login').notNullable()
table.integer('github_user_id').unique().notNullable()
table.string('node_id').notNullable()
table.string('avatar_url')
table.string('gravatar_id')
table.string('url')
table.string('html_url')
table.string('gists_url')
table.string('followers_url')
table.string('following_url')
table.string('starred_url')
table.string('subscriptions_url')
table.string('organizations_url')
table.string('repos_url')
table.string('events_url')
table.string('received_events_url')
table.string('type')
table.boolean('site_admin').notNullable()
table.string('starred_at')
table.string('user_view_type')
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable()
table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable()
})

// Add trigger to 'github_organizations' table
await knex.raw(`
CREATE TRIGGER set_updated_at_github_users
BEFORE UPDATE ON github_users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
`)
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async (knex) => {
// Drop triggers
await knex.raw('DROP TRIGGER IF EXISTS set_updated_at_github_users ON github_users;')
// Drop table
await knex.schema.dropTableIfExists('github_users')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async (knex) => {
await knex.schema.createTable('github_organization_members', (table) => {
table.increments('id').primary() // Primary key
// Foreign key to 'github_organizations' table
table
.integer('github_organization_id')
.notNullable()
.unsigned()
.references('id')
.inTable('github_organizations')
.onDelete('CASCADE') // Deletes repository if the organization is deleted
.onUpdate('CASCADE') // Updates repository if the organization ID is updated

// Foreign key to 'github_organizations' table
table
.integer('github_user_id')
.notNullable()
.unsigned()
.references('id')
.inTable('github_users')
.onDelete('CASCADE') // Deletes repository if the organization is deleted
.onUpdate('CASCADE') // Updates repository if the organization ID is updated
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async (knex) => {
// Drop table
await knex.schema.dropTableIfExists('github_organization_members')
}
Loading
Loading