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
83 changes: 83 additions & 0 deletions .github/scripts/provisioning/__tests__/github-app-auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

function decodeSegment(seg) {
return JSON.parse(Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
}

test('createAppJwt produces a verifiable RS256 token with correct claims', () => {
const now = 1_700_000_000_000;
const jwt = createAppJwt({ appId: '12345', privateKey, now });
const [headerB64, payloadB64, sigB64] = jwt.split('.');
const header = decodeSegment(headerB64);
const payload = decodeSegment(payloadB64);

assert.equal(header.alg, 'RS256');
assert.equal(payload.iss, '12345');
assert.equal(payload.iat, Math.floor(now / 1000) - 60);

const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(`${headerB64}.${payloadB64}`);
verifier.end();
const signature = Buffer.from(sigB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
assert.ok(verifier.verify(publicKey, signature));
});

test('createAppJwt rejects lifetimes over 600 seconds', () => {
assert.throws(() => createAppJwt({ appId: '1', privateKey, expiresInSeconds: 601 }), /600/);
});

test('createAppJwt requires appId and privateKey', () => {
assert.throws(() => createAppJwt({ privateKey }), /appId/);
assert.throws(() => createAppJwt({ appId: '1' }), /privateKey/);
});

test('base64url has no padding or url-unsafe chars', () => {
const out = base64url('hello world??>>');
assert.equal(/[+/=]/.test(out), false);
});

test('mintInstallationToken posts to the right endpoint and returns the token', async () => {
let captured;
const fakeFetch = async (url, opts) => {
captured = { url, opts };
return {
status: 201,
async json() {
return { token: 'ghs_faketoken', expires_at: '2026-01-01T01:00:00Z' };
}
};
};
const result = await mintInstallationToken({
appId: '12345',
privateKey,
installationId: '99',
fetchImpl: fakeFetch
});
assert.equal(result.token, 'ghs_faketoken');
assert.match(captured.url, /\/app\/installations\/99\/access_tokens$/);
assert.equal(captured.opts.method, 'POST');
assert.match(captured.opts.headers.Authorization, /^Bearer /);
});

test('mintInstallationToken throws a clear error on failure', async () => {
const fakeFetch = async () => ({
status: 403,
async text() {
return 'forbidden';
}
});
await assert.rejects(
() => mintInstallationToken({ appId: '1', privateKey, installationId: '2', fetchImpl: fakeFetch }),
/HTTP 403.*forbidden/
);
});
117 changes: 117 additions & 0 deletions .github/scripts/provisioning/__tests__/github-client.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const { createFetchClient, fromOctokit, REQUIRED_WORKFLOWS } = require('../github-client');

function fakeFetchFactory(routes) {
return async (url, opts) => {
const method = (opts && opts.method) || 'GET';
const key = `${method} ${url.replace('https://api.github.com', '')}`;
const handler = routes[key];
if (!handler) throw new Error(`Unexpected request: ${key}`);
return handler(opts);
};
}

test('REQUIRED_WORKFLOWS lists the core automation files', () => {
assert.ok(REQUIRED_WORKFLOWS.includes('pr-validation-bot.yml'));
assert.ok(REQUIRED_WORKFLOWS.includes('student-progression.yml'));
});

test('createFetchClient requires a token', () => {
assert.throws(() => createFetchClient({}), /token/);
});

test('repoExists returns true on 200 and false on 404', async () => {
const client = createFetchClient({
token: 't',
fetchImpl: fakeFetchFactory({
'GET /repos/o/yes': () => ({ status: 200 }),
'GET /repos/o/no': () => ({ status: 404 })
})
});
assert.equal(await client.repoExists({ owner: 'o', repo: 'yes' }), true);
assert.equal(await client.repoExists({ owner: 'o', repo: 'no' }), false);
});

test('createFromTemplate posts to generate endpoint', async () => {
let body;
const client = createFetchClient({
token: 't',
fetchImpl: fakeFetchFactory({
'POST /repos/o/tmpl/generate': (opts) => {
body = JSON.parse(opts.body);
return { status: 201, async json() { return { full_name: 'o/new' }; } };
}
})
});
const repo = await client.createFromTemplate({
templateOwner: 'o',
templateRepo: 'tmpl',
owner: 'o',
repo: 'new'
});
assert.equal(repo.full_name, 'o/new');
assert.equal(body.private, true);
assert.equal(body.name, 'new');
});

test('ensureCollaborator treats 201 and 204 as success', async () => {
const client = createFetchClient({
token: 't',
fetchImpl: fakeFetchFactory({
'PUT /repos/o/r/collaborators/alice': () => ({ status: 201 }),
'PUT /repos/o/r/collaborators/bob': () => ({ status: 204 })
})
});
assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'alice' })).status, 'invited');
assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'bob' })).status, 'already-collaborator');
});

test('listWorkflowPaths returns file names and [] on 404', async () => {
const client = createFetchClient({
token: 't',
fetchImpl: fakeFetchFactory({
'GET /repos/o/has/contents/.github/workflows': () => ({
status: 200,
async json() {
return [
{ type: 'file', name: 'a.yml' },
{ type: 'dir', name: 'sub' }
];
}
}),
'GET /repos/o/none/contents/.github/workflows': () => ({ status: 404 })
})
});
assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'has' }), ['a.yml']);
assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'none' }), []);
});

test('fromOctokit adapts repoExists 404 to false', async () => {
const octokit = {
rest: {
repos: {
get: async () => {
const err = new Error('nf');
err.status = 404;
throw err;
}
}
}
};
const client = fromOctokit(octokit);
assert.equal(await client.repoExists({ owner: 'o', repo: 'r' }), false);
});

test('fromOctokit listWorkflowPaths filters files and handles 404', async () => {
const octokit = {
rest: {
repos: {
getContent: async () => ({ data: [{ type: 'file', name: 'x.yml' }, { type: 'dir', name: 'd' }] })
}
}
};
const client = fromOctokit(octokit);
assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'r' }), ['x.yml']);
});
86 changes: 86 additions & 0 deletions .github/scripts/provisioning/__tests__/progress.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const {
deriveStatus,
isDay2Eligible,
completedChallenges,
closedIssueRefs,
challengeNumberFromTitle,
hasTextSignal
} = require('../progress');

test('awaiting-ack when no signals present', () => {
const { status } = deriveStatus({ comments: [], labels: [], issues: [] });
assert.equal(status, 'awaiting-ack');
});

test('ack text signal moves to active-day1', () => {
const { status, evidence } = deriveStatus({ comments: [{ body: 'ack' }] });
assert.equal(status, 'active-day1');
assert.equal(evidence.acked, true);
});

test('ack matches as a standalone token only', () => {
assert.equal(hasTextSignal([{ body: 'I acknowledge the rules' }], 'ack'), false);
assert.equal(hasTextSignal([{ body: 'ack!' }], 'ack'), true);
assert.equal(hasTextSignal([{ body: 'Ready: ack' }], 'ack'), true);
});

test('day1-complete signal moves to day1-complete', () => {
const { status } = deriveStatus({ comments: [{ body: 'day1-complete' }] });
assert.equal(status, 'day1-complete');
});

test('day2-only learners are day1-complete by default', () => {
const { status } = deriveStatus({ path: 'day2-only', comments: [] });
assert.equal(status, 'day1-complete');
});

test('day2-released label takes precedence', () => {
const { status } = deriveStatus({
comments: [{ body: 'day1-complete' }],
labels: ['day2-released']
});
assert.equal(status, 'day2-released');
});

test('needs-info label overrides everything', () => {
const { status } = deriveStatus({
comments: [{ body: 'day1-complete' }],
labels: [{ name: 'needs-info' }, 'day2-released']
});
assert.equal(status, 'needs-info');
});

test('completed challenges from closed issues, deduped and sorted', () => {
const issues = [
{ title: 'Challenge 2: file an issue', state: 'closed' },
{ title: 'Challenge 1: find your way', state: 'closed' },
{ title: 'Challenge 1: find your way', state: 'closed' },
{ title: 'Challenge 3: branch out', state: 'open' }
];
assert.deepEqual(completedChallenges(issues), [1, 2]);
});

test('a closed challenge makes a silent learner active-day1', () => {
const { status } = deriveStatus({
issues: [{ title: 'Challenge 1', state: 'closed' }]
});
assert.equal(status, 'active-day1');
});

test('closedIssueRefs parses closing keywords', () => {
assert.deepEqual(closedIssueRefs('This closes #12 and fixes #34, resolves #12'), [12, 34]);
});

test('challengeNumberFromTitle extracts the number', () => {
assert.equal(challengeNumberFromTitle('Challenge 10: capstone'), 10);
assert.equal(challengeNumberFromTitle('No number here'), null);
});

test('isDay2Eligible true only at day1-complete', () => {
assert.equal(isDay2Eligible({ comments: [{ body: 'day1-complete' }] }), true);
assert.equal(isDay2Eligible({ comments: [{ body: 'ack' }] }), false);
assert.equal(isDay2Eligible({ labels: ['day2-released'] }), false);
});
56 changes: 56 additions & 0 deletions .github/scripts/provisioning/__tests__/provision-cli.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const { parseArgs, readPrivateKey, resolveToken, usage } = require('../provision-cli');

test('parseArgs reads roster, log, dry-run, help', () => {
const a = parseArgs(['--roster', 'r.json', '--log', 'l.json', '--dry-run']);
assert.equal(a.roster, 'r.json');
assert.equal(a.log, 'l.json');
assert.equal(a.dryRun, true);
assert.equal(parseArgs(['-h']).help, true);
});

test('usage mentions both modes', () => {
assert.match(usage(), /github-app/);
assert.match(usage(), /actions-bot/);
});

test('readPrivateKey reads @file references', () => {
const tmp = path.join(os.tmpdir(), `key-${Date.now()}.pem`);
fs.writeFileSync(tmp, 'PEMDATA');
try {
assert.equal(readPrivateKey(`@${tmp}`), 'PEMDATA');
} finally {
fs.unlinkSync(tmp);
}
});

test('readPrivateKey converts literal escaped newlines', () => {
assert.equal(readPrivateKey('line1\\nline2'), 'line1\nline2');
});

test('resolveToken returns PAT in actions-bot mode', async () => {
const token = await resolveToken(
{ PROVISIONING_MODE: 'actions-bot', PROVISIONING_TOKEN: 'pat123' },
'https://api.github.com'
);
assert.equal(token, 'pat123');
});

test('resolveToken actions-bot mode requires a token', async () => {
await assert.rejects(
() => resolveToken({ PROVISIONING_MODE: 'actions-bot' }, 'https://api.github.com'),
/PROVISIONING_TOKEN/
);
});

test('resolveToken rejects unknown mode', async () => {
await assert.rejects(
() => resolveToken({ PROVISIONING_MODE: 'banana' }, 'https://api.github.com'),
/Unknown PROVISIONING_MODE/
);
});
Loading
Loading