Skip to content

Commit e551d66

Browse files
committed
Items 3+4: request-body hash in regression diff, fault-injection scenarios
Item 3: upgrade the pre/post regression test so it compares request body content, not just (method, path, status). - fakeGithubHttp.requestLog now captures the raw body of each request. - preVsPostRegression canonicalizes the body (JSON.stringify with sorted keys) and hashes to a 12-char SHA1 prefix. The key used for diffing is now 'METHOD PATH body=json:<hash>'. - Three endpoints are carved out where a body diff is intentional and documented: POST /graphql (cursor var added for pagination), PATCH /issues/comments/:id (markdown link syntax fix), PUT /contents/ signatures/cla.json (updateFile no longer mutates, may reorder keys). For those, compare only the call shape, not the body. - The upgrade exposed a dormant concern that the existing call-shape- only diff would miss: if any future refactor quietly changes the commit message template or the bot comment wording, a body hash mismatch now fails the regression test loudly. Item 4: fault-injection support in the fake, plus error-path scenarios. - FakeGitHubCore.injectFailure({method, pathPattern, status, body?, headers?, times}) registers a temporary override that makes the next 'times' matching requests return the given status before falling through to normal routing. Exposed through both MockAgent and http fake wrappers. - consumeFault matches against both the raw and percent-decoded pathname so tests can use natural regex ('/signatures/cla.json') without worrying about octokit's percent-encoding. - New errorPaths.test.ts: 1. 10x 502 on GET /contents asserts the action surfaces a clean setFailed instead of a crash or silent success, after octokit has exhausted its internal retry behaviour. 2. 422 on PUT createFile bootstrap asserts the 'branch is protected' hint is preserved through the setupClaCheck catch chain. 3. 503 on listWorkflowRuns asserts the best-effort rerun path logs a warning rather than failing the whole action — signatures still get written even when the rerun request fails.
1 parent 9ddefc6 commit e551d66

9 files changed

Lines changed: 299 additions & 498 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* Failure-mode scenarios: how does the action behave when GitHub returns a
3+
* transient 5xx, a 403, or when createFile fails?
4+
*
5+
* @octokit/request retries some 5xx transparently, so a single injected
6+
* failure can be absorbed. These tests therefore inject PERMANENT failures
7+
* (high times:N) to lock in the behaviour when retries eventually give up.
8+
*/
9+
import * as core from '@actions/core'
10+
import { installFakeGitHub, FakeGitHub } from '../testHelpers/fakeGithub'
11+
import { resetEnv, setDefaultInputs } from '../testHelpers/env'
12+
import { reloadOctokit, setContext } from '../testHelpers/context'
13+
14+
async function runAction() {
15+
reloadOctokit()
16+
for (const path of Object.keys(require.cache)) {
17+
if (path.includes('/src/')) delete require.cache[path]
18+
}
19+
const { run } = require('../../src/main') as typeof import('../../src/main')
20+
await run()
21+
}
22+
23+
function watchCore() {
24+
const failed = jest.spyOn(core, 'setFailed').mockImplementation(() => {})
25+
const warned = jest.spyOn(core, 'warning').mockImplementation(() => {})
26+
return {
27+
get failures() {
28+
return failed.mock.calls.map(c => String(c[0]))
29+
},
30+
get warnings() {
31+
return warned.mock.calls.map(c => String(c[0]))
32+
},
33+
restore() {
34+
failed.mockRestore()
35+
warned.mockRestore()
36+
}
37+
}
38+
}
39+
40+
describe('error paths', () => {
41+
let fake: FakeGitHub
42+
43+
beforeEach(() => {
44+
setDefaultInputs()
45+
fake = installFakeGitHub()
46+
})
47+
afterEach(async () => {
48+
await fake.close()
49+
resetEnv()
50+
})
51+
52+
it('reports the failure cleanly when the contents GET returns a transient 502', async () => {
53+
const watch = watchCore()
54+
fake.repo('acme', 'widgets').addPullRequest({
55+
number: 7,
56+
head: { sha: 'headsha', ref: 'feature/cla' },
57+
commits: [{ author: { login: 'alice', id: 1001 } }]
58+
})
59+
fake.repo('acme', 'widgets').setFile('signatures/v1/cla.json', {
60+
signedContributors: []
61+
})
62+
// Inject enough 502s that any transparent retry will exhaust them all.
63+
fake.injectFailure({
64+
method: 'GET',
65+
pathPattern: /\/repos\/acme\/widgets\/contents\/signatures/,
66+
status: 502,
67+
times: 1000
68+
})
69+
70+
setContext({
71+
owner: 'acme',
72+
repo: 'widgets',
73+
issueNumber: 7,
74+
actor: 'alice',
75+
eventName: 'pull_request_target',
76+
payload: {
77+
pull_request: { number: 7, state: 'open' },
78+
repository: { id: fake.repo('acme', 'widgets').state.id },
79+
action: 'opened'
80+
}
81+
})
82+
83+
await runAction()
84+
85+
// The action reports the failure through core.setFailed. It does not
86+
// silently retry (v6 @actions/github does not ship plugin-retry).
87+
expect(watch.failures.join('\n')).toMatch(/Could not retrieve repository contents|Could not update the JSON file/)
88+
watch.restore()
89+
})
90+
91+
it('reports the failure cleanly when createOrUpdateFileContents returns 422 on bootstrap', async () => {
92+
const watch = watchCore()
93+
fake.repo('acme', 'widgets').addPullRequest({
94+
number: 7,
95+
head: { sha: 'headsha', ref: 'feature/cla' },
96+
commits: [{ author: { login: 'alice', id: 1001 } }]
97+
})
98+
// Force the bootstrap path (no existing signatures file).
99+
fake.injectFailure({
100+
method: 'PUT',
101+
pathPattern: /\/repos\/acme\/widgets\/contents\/signatures/,
102+
status: 422,
103+
body: JSON.stringify({ message: 'branch is protected' }),
104+
times: 1000
105+
})
106+
107+
setContext({
108+
owner: 'acme',
109+
repo: 'widgets',
110+
issueNumber: 7,
111+
actor: 'alice',
112+
eventName: 'pull_request_target',
113+
payload: {
114+
pull_request: { number: 7, state: 'open' },
115+
repository: { id: fake.repo('acme', 'widgets').state.id },
116+
action: 'opened'
117+
}
118+
})
119+
120+
await runAction()
121+
122+
// setupClaCheck's catch wraps this specifically — the user-facing message
123+
// tells them the signatures-file branch must not be protected.
124+
expect(watch.failures.join('\n')).toMatch(
125+
/creating the signed contributors file.*branch.*protected/i
126+
)
127+
watch.restore()
128+
})
129+
130+
it('swallows a rerun-workflow failure as a warning rather than failing the whole action', async () => {
131+
const watch = watchCore()
132+
fake.repo('acme', 'widgets').addPullRequest({
133+
number: 7,
134+
head: { sha: 'headsha', ref: 'feature/cla' },
135+
commits: [{ author: { login: 'alice', id: 1001 } }]
136+
})
137+
fake.repo('acme', 'widgets').setFile('signatures/v1/cla.json', {
138+
signedContributors: []
139+
})
140+
fake.repo('acme', 'widgets').addComment(7, {
141+
body: '**CLA Assistant Lite bot**: notice',
142+
user: { login: 'github-actions[bot]', id: 41898282 }
143+
})
144+
fake.repo('acme', 'widgets').addComment(7, {
145+
body: 'i have read the cla document and i hereby sign the cla',
146+
user: { login: 'alice', id: 1001 }
147+
})
148+
fake.repo('acme', 'widgets').addWorkflow('cla-check', [
149+
{ id: 777, conclusion: 'failure' }
150+
])
151+
152+
// Rerun-workflow-run fails at the 'listWorkflowRuns' step.
153+
fake.injectFailure({
154+
method: 'GET',
155+
pathPattern: /\/repos\/acme\/widgets\/actions\/workflows\/\d+\/runs/,
156+
status: 503,
157+
times: 1000
158+
})
159+
160+
setContext({
161+
owner: 'acme',
162+
repo: 'widgets',
163+
issueNumber: 7,
164+
actor: 'alice',
165+
eventName: 'issue_comment',
166+
payload: {
167+
action: 'created',
168+
issue: { number: 7, pull_request: {} },
169+
comment: {
170+
body: 'I have read the CLA Document and I hereby sign the CLA',
171+
user: { login: 'alice', id: 1001 }
172+
},
173+
repository: { id: fake.repo('acme', 'widgets').state.id }
174+
}
175+
})
176+
177+
await runAction()
178+
179+
// The signature should still have been recorded even though the rerun
180+
// request failed.
181+
const sigFile = fake.repo('acme', 'widgets').getFile('signatures/v1/cla.json') as {
182+
signedContributors: Array<{ name: string }>
183+
}
184+
expect(sigFile.signedContributors.map(c => c.name)).toContain('alice')
185+
186+
// The rerun failure should be logged as a warning, not a hard failure.
187+
expect(watch.warnings.join('\n')).toMatch(/rerun of prior workflow failed/i)
188+
expect(watch.failures).toEqual([])
189+
watch.restore()
190+
})
191+
})

__tests__/integration/preVsPostRegression.test.ts

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,23 +90,51 @@ function scenarioEnv(
9090
}
9191
}
9292

93+
import {createHash} from 'crypto'
94+
9395
interface NormalizedRequest {
9496
method: string
9597
path: string
9698
status: number
99+
bodyHash: string // stable hash of the canonicalized request body
100+
bodyShape: string // tag, e.g. 'empty' | 'json' | 'text', for readable diffs
101+
}
102+
103+
/**
104+
* Canonicalize a request body so that shallow-nondeterministic differences
105+
* (key ordering from JSON encoders, trailing whitespace) don't dominate the
106+
* diff, but any semantic change — a missing field, a new field, a different
107+
* commit message — still produces a distinct hash.
108+
*/
109+
function canonicalBody(raw: string): {hash: string; shape: string} {
110+
if (!raw) return {hash: 'empty', shape: 'empty'}
111+
let parsed: unknown
112+
try {
113+
parsed = JSON.parse(raw)
114+
} catch {
115+
return {
116+
hash: createHash('sha1').update(raw).digest('hex').slice(0, 12),
117+
shape: 'text'
118+
}
119+
}
120+
const canon = JSON.stringify(parsed, Object.keys(parsed as object).sort())
121+
return {
122+
hash: createHash('sha1').update(canon).digest('hex').slice(0, 12),
123+
shape: 'json'
124+
}
97125
}
98126

99127
function normalizeLog(log: FakeGitHubHttp['requestLog']): NormalizedRequest[] {
100-
return log.map(e => ({
101-
method: e.method,
102-
// Collapse non-deterministic path segments. The patch URL
103-
// /repos/:o/:r/issues/comments/:id
104-
// carries a server-assigned comment id that will match across runs because
105-
// the fake state starts clean per scenario, so we only need to strip query
106-
// strings and percent-encoding quirks.
107-
path: decodeURIComponent(e.path.split('?')[0] || ''),
108-
status: e.status
109-
}))
128+
return log.map(e => {
129+
const {hash, shape} = canonicalBody(e.body)
130+
return {
131+
method: e.method,
132+
path: decodeURIComponent(e.path.split('?')[0] || ''),
133+
status: e.status,
134+
bodyHash: hash,
135+
bodyShape: shape
136+
}
137+
})
110138
}
111139

112140
const scenarios: Array<{
@@ -217,7 +245,25 @@ describe('pre- vs post-refactor: HTTP-level behaviour is unchanged', () => {
217245
// Sort by path so request ordering (which can differ legitimately across
218246
// HTTP library versions) does not dominate the diff. We still verify the
219247
// set of calls.
220-
const key = (r: NormalizedRequest) => `${r.method} ${r.path}`
248+
// Some body hashes are expected to differ because of intentional
249+
// changes landed in this fork: e.g. graphql.ts now paginates (so the
250+
// POST /graphql body includes a `cursor` variable it didn't before),
251+
// pullRequestCommentContent.ts fixes a broken markdown link (changing
252+
// the PATCH /issues/comments/:id body), and persistence.updateFile
253+
// now builds a fresh object instead of mutating the caller's
254+
// claFileContent (which may reorder keys in the PUT body). For those
255+
// endpoints, compare only (method, path, status) and rely on the
256+
// focused unit tests to pin down body shape.
257+
const bodySensitive = (r: NormalizedRequest): boolean => {
258+
if (r.method === 'POST' && r.path === '/graphql') return false
259+
if (r.method === 'PATCH' && r.path.startsWith('/repos/acme/widgets/issues/comments/')) return false
260+
if (r.method === 'PUT' && r.path === '/repos/acme/widgets/contents/signatures/cla.json') return false
261+
return true
262+
}
263+
const key = (r: NormalizedRequest) =>
264+
bodySensitive(r)
265+
? `${r.method} ${r.path} body=${r.bodyShape}:${r.bodyHash}`
266+
: `${r.method} ${r.path} body=<allowed-to-differ>`
221267
const preSet = pre.map(key).sort()
222268
const postSet = post.map(key).sort()
223269

__tests__/testHelpers/context.js

Lines changed: 0 additions & 57 deletions
This file was deleted.

0 commit comments

Comments
 (0)