Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ test('registration workflow contains required label and duplicate/waitlist flows
"labels: ['duplicate']",
"labels: ['waitlist']",
"labels: ['registration']",
'CLASSROOM_DAY1_ASSIGNMENT_URL',
'CLASSROOM_DAY2_ASSIGNMENT_URL',
'Please reply `ack` on this issue after you confirm your Day 1 link works.',
'Please reply `ack` on this issue after you receive your learning room invitation.',
'Upload CSV as artifact',
'Sync Student Roster (No PII)',
// Hybrid model: enrollment triggers provisioning directly.
'createWorkflowDispatch',
// PII hygiene: the [REGISTER] path stores intake privately, then redacts.
'Registration submission (redacted)',
];

requiredSnippets.forEach(snippet => {
Expand All @@ -31,6 +33,14 @@ test('registration workflow contains required label and duplicate/waitlist flows
`Registration workflow missing expected behavior marker: ${snippet}`
);
});

// GitHub Classroom was removed in the June 2026 Hybrid transition; the
// workflow must never send registrants to classroom.github.com again.
assert.equal(
/CLASSROOM_DAY[12]_ASSIGNMENT_URL|classroom\.github\.com/i.test(workflow),
false,
'Registration workflow must not reference removed GitHub Classroom assignment links'
);
});

test('registration issue form template exists', () => {
Expand Down
130 changes: 128 additions & 2 deletions .github/scripts/provisioning/__tests__/github-app-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');

const { createAppJwt, mintInstallationToken, base64url } = require('../github-app-auth');
const {
createAppJwt,
mintInstallationToken,
base64url,
normalizeInstallationId,
discoverInstallationId
} = require('../github-app-auth');

const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
Expand Down Expand Up @@ -69,6 +75,29 @@ test('mintInstallationToken posts to the right endpoint and returns the token',
assert.match(captured.opts.headers.Authorization, /^Bearer /);
});

test('mintInstallationToken accepts installation settings URLs', async () => {
let captured;
const fakeFetch = async (url) => {
captured = url;
return {
status: 201,
async json() {
return { token: 'ghs_faketoken', expires_at: '2026-01-01T01:00:00Z' };
}
};
};

await mintInstallationToken({
appId: '12345',
privateKey,
installationId:
'https://github.com/organizations/Community-Access/settings/installations/99?target_type=Organization',
fetchImpl: fakeFetch
});

assert.match(captured, /\/app\/installations\/99\/access_tokens$/);
});

test('mintInstallationToken throws a clear error on failure', async () => {
const fakeFetch = async () => ({
status: 403,
Expand All @@ -78,6 +107,103 @@ test('mintInstallationToken throws a clear error on failure', async () => {
});
await assert.rejects(
() => mintInstallationToken({ appId: '1', privateKey, installationId: '2', fetchImpl: fakeFetch }),
/HTTP 403.*forbidden/
/HTTP 403.*forbidden\./
);
});

test('normalizeInstallationId accepts numeric IDs and installation URLs', () => {
assert.equal(normalizeInstallationId('12345'), '12345');
assert.equal(
normalizeInstallationId(
'https://github.com/organizations/Community-Access/settings/installations/98765432'
),
'98765432'
);
});

test('normalizeInstallationId rejects invalid values', () => {
assert.throws(() => normalizeInstallationId(''), /requires installationId/);
assert.throws(() => normalizeInstallationId('not-an-installation-id'), /Invalid installationId/);
});

test('discoverInstallationId finds the installation for the target owner', async () => {
let captured;
const fakeFetch = async (url, opts) => {
captured = { url, opts };
return {
status: 200,
async json() {
return [
{ id: 111, account: { login: 'Someone-Else' } },
{ id: 222, account: { login: 'Community-Access' } }
];
}
};
};
const id = await discoverInstallationId({
appId: '12345',
privateKey,
owner: 'community-access',
fetchImpl: fakeFetch
});
assert.equal(id, '222');
assert.match(captured.url, /\/app\/installations\?per_page=100$/);
assert.match(captured.opts.headers.Authorization, /^Bearer /);
});

test('discoverInstallationId explains when the app is not installed on the owner', async () => {
const fakeFetch = async () => ({
status: 200,
async json() {
return [{ id: 111, account: { login: 'Someone-Else' } }];
}
});
await assert.rejects(
() =>
discoverInstallationId({
appId: '12345',
privateKey,
owner: 'Community-Access',
fetchImpl: fakeFetch
}),
/not installed on Community-Access/
);
});

test('discoverInstallationId explains when the app has no installations at all', async () => {
const fakeFetch = async () => ({
status: 200,
async json() {
return [];
}
});
await assert.rejects(
() =>
discoverInstallationId({
appId: '12345',
privateKey,
owner: 'Community-Access',
fetchImpl: fakeFetch
}),
/no installations.*Install the App/i
);
});

test('discoverInstallationId surfaces API errors', async () => {
const fakeFetch = async () => ({
status: 401,
async text() {
return 'bad credentials';
}
});
await assert.rejects(
() =>
discoverInstallationId({
appId: '12345',
privateKey,
owner: 'Community-Access',
fetchImpl: fakeFetch
}),
/HTTP 401.*bad credentials/
);
});
69 changes: 69 additions & 0 deletions .github/scripts/provisioning/__tests__/provision-cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ test('parseArgs reads roster, log, dry-run, help', () => {
assert.equal(parseArgs(['-h']).help, true);
});

test('parseArgs reads check-auth', () => {
assert.equal(parseArgs(['--check-auth']).checkAuth, true);
assert.equal(parseArgs(['--roster', 'r.json']).checkAuth, false);
});

test('usage mentions both modes', () => {
assert.match(usage(), /github-app/);
assert.match(usage(), /actions-bot/);
Expand Down Expand Up @@ -54,3 +59,67 @@ test('resolveToken rejects unknown mode', async () => {
/Unknown PROVISIONING_MODE/
);
});

const crypto = require('node:crypto');
const { privateKey: testPem } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

function fakeAppApi() {
const calls = [];
const fetchImpl = async (url, opts) => {
calls.push({ url, opts });
if (/\/app\/installations\?/.test(url)) {
return {
status: 200,
async json() {
return [{ id: 777, account: { login: 'Community-Access' } }];
}
};
}
if (/\/app\/installations\/777\/access_tokens$/.test(url)) {
return {
status: 201,
async json() {
return { token: 'ghs_discovered', expires_at: '2026-01-01T01:00:00Z' };
}
};
}
return { status: 404, async text() { return 'unexpected call'; } };
};
return { calls, fetchImpl };
}

test('resolveToken github-app mode discovers the installation when the ID is unset', async () => {
const { fetchImpl } = fakeAppApi();
const token = await resolveToken(
{
PROVISIONING_MODE: 'github-app',
PROVISIONING_APP_ID: '12345',
PROVISIONING_APP_PRIVATE_KEY: testPem,
PROVISIONING_STUDENT_OWNER: 'Community-Access'
},
'https://api.github.com',
{ fetchImpl }
);
assert.equal(token, 'ghs_discovered');
});

test('resolveToken github-app mode uses a configured installation ID directly', async () => {
const { calls, fetchImpl } = fakeAppApi();
const token = await resolveToken(
{
PROVISIONING_MODE: 'github-app',
PROVISIONING_APP_ID: '12345',
PROVISIONING_APP_PRIVATE_KEY: testPem,
PROVISIONING_APP_INSTALLATION_ID: '777',
PROVISIONING_STUDENT_OWNER: 'Community-Access'
},
'https://api.github.com',
{ fetchImpl }
);
assert.equal(token, 'ghs_discovered');
assert.equal(calls.some((c) => /\/app\/installations\?/.test(c.url)), false);
});
104 changes: 104 additions & 0 deletions .github/scripts/provisioning/__tests__/sync-intake-to-roster.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const {
parseIntakeIssue,
syncIntakeToRoster
} = require('../sync-intake-to-roster');

function intakeIssue({ number = 1, login = 'octocat', captured = '2026-06-05T12:00:00.000Z', title } = {}) {
return {
number,
title: title || `[ENROLL-INTAKE] Test Person ${number}`,
created_at: '2026-06-05T11:59:00Z',
body: [
'## Classroom Enrollment Intake',
'',
'- Public issue: https://github.com/Community-Access/git-going-with-github/issues/42',
`- Submitted by: @${login}`,
'- Full Name: Test Person',
'- Email Address: test@example.com',
'',
`- Captured at: ${captured}`
].join('\n')
};
}

test('parseIntakeIssue extracts handle and registration time', () => {
const parsed = parseIntakeIssue(intakeIssue({ login: 'Some-User' }));
assert.equal(parsed.github_handle, 'Some-User');
assert.equal(parsed.registered_at, '2026-06-05T12:00:00.000Z');
});

test('parseIntakeIssue falls back to issue created_at and rejects non-intake issues', () => {
const noCapture = intakeIssue();
noCapture.body = noCapture.body.replace(/- Captured at: .*/, '');
assert.equal(parseIntakeIssue(noCapture).registered_at, '2026-06-05T11:59:00Z');

assert.equal(parseIntakeIssue({ title: 'Some other issue', body: '' }), null);
const badHandle = intakeIssue();
badHandle.body = badHandle.body.replace(/@octocat/, '@-bad-handle-');
assert.equal(parseIntakeIssue(badHandle), null);
});

test('syncIntakeToRoster adds pending learners and the cohort, idempotently', () => {
const roster = { version: 1, cohorts: [], learners: [] };
const issues = [
intakeIssue({ number: 1, login: 'alice' }),
intakeIssue({ number: 2, login: 'bob' }),
intakeIssue({ number: 3, login: 'alice' }) // duplicate submission
];
const result = syncIntakeToRoster({ roster, intakeIssues: issues, cohortId: 'self-paced-2026' });

assert.deepEqual(result.added.sort(), ['alice', 'bob']);
assert.equal(result.roster.learners.length, 2);
assert.ok(result.roster.learners.every((l) => l.provision_state === 'pending'));
assert.ok(result.roster.cohorts.some((c) => c.cohort_id === 'self-paced-2026'));

// Second run adds nothing.
const again = syncIntakeToRoster({
roster: result.roster,
intakeIssues: issues,
cohortId: 'self-paced-2026'
});
assert.deepEqual(again.added, []);
assert.equal(again.roster.learners.length, 2);
});

test('syncIntakeToRoster never resets an already-provisioned learner', () => {
const roster = {
version: 1,
cohorts: [{ cohort_id: 'self-paced-2026', label: 'Self-paced' }],
learners: [
{
github_handle: 'alice',
cohort_id: 'self-paced-2026',
path: 'day1-day2',
learning_room_repo: 'Community-Access/learning-room-self-paced-2026-alice',
provision_state: 'provisioned',
status: 'active-day1',
registered_at: '2026-06-01T00:00:00Z',
last_signal_at: null,
notes: ''
}
]
};
const result = syncIntakeToRoster({
roster,
intakeIssues: [intakeIssue({ login: 'alice' })],
cohortId: 'self-paced-2026'
});
assert.deepEqual(result.added, []);
assert.equal(result.roster.learners[0].provision_state, 'provisioned');
assert.equal(
result.roster.learners[0].learning_room_repo,
'Community-Access/learning-room-self-paced-2026-alice'
);
});

test('syncIntakeToRoster requires a cohort id', () => {
assert.throws(
() => syncIntakeToRoster({ roster: { version: 1, cohorts: [], learners: [] }, intakeIssues: [] }),
/cohortId/
);
});
Loading
Loading